9184 lines
326 KiB
JavaScript
9184 lines
326 KiB
JavaScript
const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
|
||
// 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
|
||
if ("serviceWorker" in navigator) {
|
||
window.addEventListener("load", () => {
|
||
navigator.serviceWorker
|
||
.register("/tool/speakr/static/sw.js")
|
||
.then((registration) => {
|
||
console.log(
|
||
"ServiceWorker registration successful with scope: ",
|
||
registration.scope
|
||
);
|
||
})
|
||
.catch((error) => {
|
||
console.log("ServiceWorker registration failed: ", error);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Create a safe t function that's always available
|
||
const safeT = (key, params = {}) => {
|
||
if (!window.i18n || !window.i18n.t) {
|
||
return key; // Return key as fallback without warning during initial render
|
||
}
|
||
return window.i18n.t(key, params);
|
||
};
|
||
let webReader = null;
|
||
let webReader2 = null;
|
||
// ASR Recorder 实例(提升到外层以便跨录音周期清理)
|
||
let rec = null;
|
||
let rec2 = null;
|
||
// openMixedStream 创建的 AudioContext(需跨周期清理)
|
||
let asrAudioContext = null;
|
||
let asrAudioContext2 = null;
|
||
// 用于标记是否需要重置转录状态(暂停/恢复时使用)
|
||
let resetTranscriptionFlag = false;
|
||
let pendingCorrectionCount = 0;
|
||
//--------------------页面启动与 Vue 初始化 end------------------
|
||
|
||
//------------------- Vue 应用主体定义 start------------------
|
||
const app = createApp({
|
||
setup() {
|
||
//------------------- 响应式状态与实时转写基础状态 start------------------
|
||
// --- Core State ---
|
||
// 新增说话人占位
|
||
let segmentList = [];
|
||
let lastSpeaker = "";
|
||
let configwords = "";
|
||
const currentView = ref("upload"); // 'upload' or 'recording'
|
||
const dragover = ref(false);
|
||
const recordings = ref([]);
|
||
const selectedRecording = ref(null);
|
||
const selectedTab = ref("summary"); // 'summary' or 'notes'
|
||
const searchQuery = ref("");
|
||
const isLoadingRecordings = ref(true);
|
||
const globalError = ref(null);
|
||
|
||
// Advanced filter state
|
||
const showAdvancedFilters = ref(false);
|
||
const filterTags = ref([]); // Selected tag IDs for filtering
|
||
const filterDateRange = ref({ start: "", end: "" });
|
||
const filterDatePreset = ref(""); // 'today', 'yesterday', 'week', 'month', etc.
|
||
const filterTextQuery = ref("");
|
||
|
||
// --- Pagination State ---
|
||
const currentPage = ref(1);
|
||
const perPage = ref(25);
|
||
const totalRecordings = ref(0);
|
||
const totalPages = ref(0);
|
||
const hasNextPage = ref(false);
|
||
const hasPrevPage = ref(false);
|
||
const isLoadingMore = ref(false);
|
||
const searchDebounceTimer = ref(null);
|
||
|
||
// --- Enhanced Search & Organization State ---
|
||
const sortBy = ref("created_at"); // 'created_at' or 'meeting_date'
|
||
const selectedTagFilter = ref(null); // For filtering by clicked tag
|
||
|
||
// --- UI State ---
|
||
const browser = ref("unknown");
|
||
const isSidebarCollapsed = ref(false);
|
||
const searchTipsExpanded = ref(false);
|
||
const isUserMenuOpen = ref(false);
|
||
const isDarkMode = ref(false);
|
||
const currentColorScheme = ref("blue");
|
||
const showColorSchemeModal = ref(false);
|
||
const windowWidth = ref(window.innerWidth);
|
||
const mobileTab = ref("transcript");
|
||
const isMetadataExpanded = ref(false);
|
||
|
||
// --- i18n State ---
|
||
const currentLanguage = ref("en");
|
||
const currentLanguageName = ref("English");
|
||
const availableLanguages = ref([]);
|
||
const showLanguageMenu = ref(false);
|
||
|
||
// --- Upload State ---
|
||
const uploadQueue = ref([]);
|
||
const currentlyProcessingFile = ref(null);
|
||
const processingProgress = ref(0);
|
||
const processingMessage = ref("");
|
||
const isProcessingActive = ref(false);
|
||
const pollInterval = ref(null);
|
||
const progressPopupMinimized = ref(false);
|
||
const progressPopupClosed = ref(false);
|
||
const maxFileSizeMB = ref(250); // Default value, will be updated from API
|
||
const chunkingEnabled = ref(true); // Default value, will be updated from API
|
||
const chunkingMode = ref("size"); // 'size' or 'duration', will be updated from API
|
||
const chunkingLimit = ref(20); // Value in MB or seconds, will be updated from API
|
||
const chunkingLimitDisplay = ref("20MB"); // Human readable display, will be updated from API
|
||
const recordingDisclaimer = ref(""); // Recording disclaimer text from admin settings
|
||
const showRecordingDisclaimerModal = ref(false); // Controls disclaimer modal visibility
|
||
const pendingRecordingMode = ref(null); // Stores the recording mode while showing disclaimer
|
||
|
||
// --- Audio Recording State ---
|
||
const editingDraftId = ref(null);
|
||
const editingDraftName = ref('');
|
||
const draftNameInput = ref(null);
|
||
|
||
const isRecording = ref(false);
|
||
const isStoppingRecording = ref(false);
|
||
const mediaRecorder = ref(null);
|
||
const audioChunks = ref([]);
|
||
const audioBlobURL = ref(null);
|
||
const recordingTime = ref(0);
|
||
const recordingInterval = ref(null);
|
||
const canRecordAudio = ref(
|
||
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||
);
|
||
const canRecordSystemAudio = ref(false);
|
||
const systemAudioSupported = ref(false);
|
||
const systemAudioError = ref("");
|
||
const recordingNotes = ref("");
|
||
const showSystemAudioHelp = ref(false);
|
||
// ASR options for recording view
|
||
const asrLanguage = ref(""); // Empty string for auto-detect
|
||
const asrMinSpeakers = ref(""); // Empty string for auto-detect
|
||
const asrMaxSpeakers = ref(""); // Empty string for auto-detect
|
||
const audioContext = ref(null);
|
||
const analyser = ref(null);
|
||
const micAnalyser = ref(null);
|
||
const systemAnalyser = ref(null);
|
||
const visualizer = ref(null);
|
||
const micVisualizer = ref(null);
|
||
const systemVisualizer = ref(null);
|
||
const animationFrameId = ref(null);
|
||
const recordingMode = ref("microphone"); // 'microphone', 'system', or 'both'
|
||
const activeStreams = ref([]); // Track active streams for cleanup
|
||
|
||
// --- Recording Size Monitoring ---
|
||
const estimatedFileSize = ref(0);
|
||
const fileSizeWarningShown = ref(false);
|
||
const recordingQuality = ref("optimized"); // 'optimized', 'standard', 'high'
|
||
const actualBitrate = ref(0);
|
||
const maxRecordingMB = ref(200); // Maximum recording size before auto-stop
|
||
const sizeCheckInterval = ref(null);
|
||
|
||
// --- Draft/Pause Recording State ---
|
||
const isPaused = ref(false);
|
||
const currentDraftId = ref(null);
|
||
const pausedDuration = ref(0); // 暂停前的累计时长
|
||
const pausedTranscription = ref(''); // 暂停前的转录文本
|
||
const draftRecordings = ref([]); // 草稿列表
|
||
const draftsExpanded = ref(true); // 草稿列表是否展开
|
||
|
||
// Advanced Options for ASR
|
||
const showAdvancedOptions = ref(false);
|
||
const uploadLanguage = ref(""); // Empty string for auto-detect
|
||
const uploadMinSpeakers = ref(""); // Empty string for auto-detect
|
||
const uploadMaxSpeakers = ref(""); // Empty string for auto-detect
|
||
|
||
// Tag Selection
|
||
const availableTags = ref([]);
|
||
const selectedTagIds = ref([]); // Changed to array for multiple selection
|
||
const uploadTagSearchFilter = ref(""); // For filtering tags in upload view
|
||
const selectedTags = computed(() => {
|
||
return selectedTagIds.value
|
||
.map((tagId) => availableTags.value.find((tag) => tag.id == tagId))
|
||
.filter(Boolean); // Filter out undefined tags
|
||
});
|
||
// 自动判断是否为 Markdown
|
||
const isMarkdown = computed(() => {
|
||
const v = analysisResult.value;
|
||
return (
|
||
v.includes("#") ||
|
||
v.includes("**") ||
|
||
v.includes("```") ||
|
||
v.includes("* ") ||
|
||
v.includes("- ")
|
||
);
|
||
});
|
||
|
||
// 解析 Markdown → HTML
|
||
const renderedMarkdown = Vue.computed(() => {
|
||
return window.marked.parse(analysisResult.value || "", {
|
||
breaks: true,
|
||
});
|
||
});
|
||
// Computed property for filtered available tags in upload view
|
||
const filteredAvailableTagsForUpload = computed(() => {
|
||
const availableForSelection = availableTags.value.filter(
|
||
(tag) => !selectedTagIds.value.includes(tag.id)
|
||
);
|
||
if (!uploadTagSearchFilter.value) return availableForSelection;
|
||
|
||
const filter = uploadTagSearchFilter.value.toLowerCase();
|
||
return availableForSelection.filter((tag) =>
|
||
tag.name.toLowerCase().includes(filter)
|
||
);
|
||
});
|
||
|
||
// --- Modal State ---
|
||
const showEditModal = ref(false);
|
||
const showDeleteModal = ref(false);
|
||
const showEditTagsModal = ref(false);
|
||
const selectedNewTagId = ref("");
|
||
const tagSearchFilter = ref(""); // For filtering tags in the modal
|
||
const showReprocessModal = ref(false);
|
||
const showResetModal = ref(false);
|
||
const showSpeakerModal = ref(false);
|
||
const showShareModal = ref(false);
|
||
const showSharesListModal = ref(false);
|
||
const showTextEditorModal = ref(false);
|
||
const showAsrEditorModal = ref(false);
|
||
const editingRecording = ref(null);
|
||
const editingTranscriptionContent = ref("");
|
||
const editingSegments = ref([]);
|
||
const availableSpeakers = ref([]);
|
||
const recordingToShare = ref(null);
|
||
const shareOptions = reactive({
|
||
share_summary: true,
|
||
share_notes: true,
|
||
});
|
||
const generatedShareLink = ref("");
|
||
const existingShareDetected = ref(false);
|
||
const userShares = ref([]);
|
||
const isLoadingShares = ref(false);
|
||
const shareToDelete = ref(null);
|
||
const showShareDeleteModal = ref(false);
|
||
const recordingToDelete = ref(null);
|
||
const recordingToReset = ref(null);
|
||
const reprocessType = ref(null); // 'transcription' or 'summary'
|
||
const reprocessRecording = ref(null);
|
||
const isAutoIdentifying = ref(false);
|
||
const asrReprocessOptions = reactive({
|
||
language: "",
|
||
min_speakers: null,
|
||
max_speakers: null,
|
||
});
|
||
const speakerMap = ref({});
|
||
const regenerateSummaryAfterSpeakerUpdate = ref(true);
|
||
const speakerSuggestions = ref({});
|
||
const loadingSuggestions = ref({});
|
||
const activeSpeakerInput = ref(null);
|
||
|
||
// --- Inline Editing State ---
|
||
const editingParticipants = ref(false);
|
||
const editingMeetingDate = ref(false);
|
||
const editingSummary = ref(false);
|
||
const editingNotes = ref(false);
|
||
const tempNotesContent = ref("");
|
||
const tempSummaryContent = ref("");
|
||
const autoSaveTimer = ref(null);
|
||
const autoSaveDelay = 2000; // 2 seconds debounce
|
||
|
||
// --- Markdown Editor State ---
|
||
const notesMarkdownEditor = ref(null);
|
||
const markdownEditorInstance = ref(null);
|
||
const summaryMarkdownEditor = ref(null);
|
||
const summaryMarkdownEditorInstance = ref(null);
|
||
const recordingNotesEditor = ref(null);
|
||
const recordingMarkdownEditorInstance = ref(null);
|
||
|
||
// --- Transcription State ---
|
||
const transcriptionViewMode = ref("simple"); // 'simple' or 'bubble'
|
||
const legendExpanded = ref(false);
|
||
const highlightedSpeaker = ref(null);
|
||
|
||
// --- Chat State ---
|
||
const showChat = ref(false);
|
||
const isChatMaximized = ref(false);
|
||
const chatMessages = ref([]);
|
||
const chatInput = ref("");
|
||
const isChatLoading = ref(false);
|
||
const chatMessagesRef = ref(null);
|
||
|
||
// --- Audio Player State ---
|
||
const playerVolume = ref(1.0);
|
||
|
||
// --- Column Resizing State ---
|
||
const leftColumnWidth = ref(60); // 60% for left column (transcript)
|
||
const rightColumnWidth = ref(40); // 40% for right column (summary/chat)
|
||
const isResizing = ref(false);
|
||
|
||
// --- App Configuration ---
|
||
const useAsrEndpoint = ref(false);
|
||
const currentUserName = ref("");
|
||
const asrResult = ref("");
|
||
const asrResultOnline = ref("");
|
||
const asrResultOffline = ref("");
|
||
const asrResultTextarea = ref("");
|
||
const asrResultTextareaOnline = ref("");
|
||
const sumupResultTextarea = ref("");
|
||
const baseInfo = ref({ name: "领导发言稿", fontSize: 40 });
|
||
const isModalOpen = ref(false);
|
||
|
||
const asrTextarea = ref(null);
|
||
const analysisResult = ref("");
|
||
const isDialogVisible = ref(false);
|
||
const isyjDialogVisible = ref(false);
|
||
const lastTimer = ref(0);
|
||
const urlmsg = ref("获取链接中...");
|
||
const urlinfo = ref("");
|
||
const zjloding = ref(false);
|
||
const showSpeaker = ref(true);
|
||
|
||
// --- 转录文本编辑状态 ---
|
||
const isEditingTranscription = ref(false);
|
||
const editableText = ref("");
|
||
const fullRealtimeTranscription = computed(() => {
|
||
return (
|
||
(asrResult.value || "") +
|
||
(asrResultOffline.value || "") +
|
||
(asrResultOnline.value || "")
|
||
);
|
||
});
|
||
let segmentRenderStartIndex = 0;
|
||
let liveTranscriptionEpoch = 0;
|
||
let realtimeTimestampBarrierMs = -1;
|
||
let realtimeEditCarryover = null;
|
||
let clearRealtimePipelineRef = null;
|
||
let getLatestRealtimeTimestampRef = null;
|
||
let buildRealtimeEditCarryoverRef = null;
|
||
let renderFullTextRef = null;
|
||
let editModeBufferStartIndex = 0;
|
||
|
||
function startNewRealtimeEpoch({
|
||
barrierTimestampMs = null,
|
||
clearTimestampBarrier = false,
|
||
clearEditCarryover = true,
|
||
} = {}) {
|
||
liveTranscriptionEpoch += 1;
|
||
|
||
if (clearEditCarryover) {
|
||
realtimeEditCarryover = null;
|
||
}
|
||
|
||
if (clearTimestampBarrier) {
|
||
realtimeTimestampBarrierMs = -1;
|
||
} else if (Number.isFinite(barrierTimestampMs)) {
|
||
realtimeTimestampBarrierMs = Math.max(
|
||
realtimeTimestampBarrierMs,
|
||
barrierTimestampMs
|
||
);
|
||
}
|
||
|
||
if (clearRealtimePipelineRef) {
|
||
clearRealtimePipelineRef({
|
||
preserveTimestampBarrier: !clearTimestampBarrier,
|
||
});
|
||
}
|
||
}
|
||
|
||
//--------------------响应式状态与实时转写基础状态 end------------------
|
||
|
||
//------------------- 计算属性与派生展示数据 start------------------
|
||
// --- Computed Properties ---
|
||
const isMobileScreen = computed(() => {
|
||
return windowWidth.value < 1024;
|
||
});
|
||
|
||
const datePresetOptions = computed(() => {
|
||
return [
|
||
{ value: "today", label: t("sidebar.today") },
|
||
{ value: "yesterday", label: t("sidebar.yesterday") },
|
||
{ value: "thisweek", label: t("sidebar.thisWeek") },
|
||
{ value: "lastweek", label: t("sidebar.lastWeek") },
|
||
{ value: "thismonth", label: t("sidebar.thisMonth") },
|
||
{ value: "lastmonth", label: t("sidebar.lastMonth") },
|
||
];
|
||
});
|
||
|
||
const languageOptions = computed(() => {
|
||
return [
|
||
{ value: "", label: t("form.autoDetect") },
|
||
{ value: "en", label: t("languages.en") },
|
||
{ value: "es", label: t("languages.es") },
|
||
{ value: "fr", label: t("languages.fr") },
|
||
{ value: "de", label: t("languages.de") },
|
||
{ value: "it", label: t("languages.it") },
|
||
{ value: "pt", label: t("languages.pt") },
|
||
{ value: "nl", label: t("languages.nl") },
|
||
{ value: "ru", label: t("languages.ru") },
|
||
{ value: "zh", label: t("languages.zh") },
|
||
{ value: "ja", label: t("languages.ja") },
|
||
{ value: "ko", label: t("languages.ko") },
|
||
];
|
||
});
|
||
|
||
const filteredRecordings = computed(() => {
|
||
return recordings.value;
|
||
});
|
||
|
||
const highlightedTranscript = computed(() => {
|
||
if (!selectedRecording.value?.transcription) return "";
|
||
let html = selectedRecording.value.transcription;
|
||
// Escape HTML to prevent injection, but keep it minimal
|
||
html = html.replace(/</g, "<").replace(/>/g, ">");
|
||
|
||
// 1. Replace newlines with <br> tags for proper line breaks in HTML
|
||
html = html.replace(/\n/g, "<br>");
|
||
|
||
// 2. Get speaker colors from the speakerMap if available
|
||
const speakerColors = {};
|
||
if (speakerMap.value) {
|
||
Object.keys(speakerMap.value).forEach((speaker, index) => {
|
||
speakerColors[speaker] =
|
||
speakerMap.value[speaker].color ||
|
||
`speaker-color-${(index % 8) + 1}`;
|
||
});
|
||
}
|
||
|
||
// 3. Wrap each speaker tag in a span for styling and interaction with colors
|
||
html = html.replace(/\[([^\]]+)\]/g, (match, speakerId) => {
|
||
const isHighlighted = speakerId === highlightedSpeaker.value;
|
||
const colorClass = speakerColors[speakerId] || "";
|
||
// Replace speaker ID with name if available
|
||
const displayName = speakerMap.value[speakerId]?.name || speakerId;
|
||
const displayText = `[${displayName}]`;
|
||
// Use a more specific and stylish class structure with color
|
||
return `<span class="speaker-tag ${colorClass} ${
|
||
isHighlighted ? "speaker-highlight" : ""
|
||
}" data-speaker-id="${speakerId}">${displayText}</span>`;
|
||
});
|
||
|
||
return html;
|
||
});
|
||
|
||
const activeRecordingMetadata = computed(() => {
|
||
if (!selectedRecording.value) return [];
|
||
|
||
const recording = selectedRecording.value;
|
||
const metadata = [];
|
||
|
||
if (recording.created_at) {
|
||
metadata.push({
|
||
icon: "fas fa-history",
|
||
text: formatDisplayDate(recording.created_at),
|
||
});
|
||
}
|
||
|
||
if (recording.file_size) {
|
||
metadata.push({
|
||
icon: "fas fa-file-audio",
|
||
text: formatFileSize(recording.file_size),
|
||
});
|
||
}
|
||
|
||
if (recording.duration) {
|
||
metadata.push({
|
||
icon: "fas fa-clock",
|
||
text: formatDuration(recording.duration),
|
||
});
|
||
}
|
||
|
||
if (recording.original_filename) {
|
||
const maxLength = 30;
|
||
const truncated =
|
||
recording.original_filename.length > maxLength
|
||
? recording.original_filename.substring(0, maxLength) + "..."
|
||
: recording.original_filename;
|
||
metadata.push({
|
||
icon: "fas fa-file",
|
||
text: truncated,
|
||
fullText: recording.original_filename,
|
||
});
|
||
}
|
||
|
||
// Add tags to metadata
|
||
if (recording.tags && recording.tags.length > 0) {
|
||
metadata.push({
|
||
icon: "fas fa-tags",
|
||
text: "", // Empty text since we'll render tags specially
|
||
tags: recording.tags, // Pass the tags array
|
||
isTagItem: true, // Flag to identify this as a tag item
|
||
});
|
||
}
|
||
|
||
return metadata;
|
||
});
|
||
|
||
const groupedRecordings = computed(() => {
|
||
// Sort recordings based on the selected sort criteria
|
||
const sortedRecordings = [...filteredRecordings.value].sort((a, b) => {
|
||
const dateA = getDateForSorting(a);
|
||
const dateB = getDateForSorting(b);
|
||
|
||
// Handle null dates (put them at the end)
|
||
if (!dateA && !dateB) return 0;
|
||
if (!dateA) return 1;
|
||
if (!dateB) return -1;
|
||
|
||
return dateB - dateA; // Most recent first
|
||
});
|
||
|
||
const groups = {
|
||
today: [],
|
||
yesterday: [],
|
||
thisWeek: [],
|
||
lastWeek: [],
|
||
thisMonth: [],
|
||
lastMonth: [],
|
||
older: [],
|
||
};
|
||
|
||
sortedRecordings.forEach((recording) => {
|
||
const date = getDateForSorting(recording);
|
||
if (!date) {
|
||
groups.older.push(recording);
|
||
return;
|
||
}
|
||
|
||
if (isToday(date)) {
|
||
groups.today.push(recording);
|
||
} else if (isYesterday(date)) {
|
||
groups.yesterday.push(recording);
|
||
} else if (isThisWeek(date)) {
|
||
groups.thisWeek.push(recording);
|
||
} else if (isLastWeek(date)) {
|
||
groups.lastWeek.push(recording);
|
||
} else if (isThisMonth(date)) {
|
||
groups.thisMonth.push(recording);
|
||
} else if (isLastMonth(date)) {
|
||
groups.lastMonth.push(recording);
|
||
} else {
|
||
groups.older.push(recording);
|
||
}
|
||
});
|
||
|
||
return [
|
||
{ title: t("sidebar.today"), items: groups.today },
|
||
{ title: t("sidebar.yesterday"), items: groups.yesterday },
|
||
{ title: t("sidebar.thisWeek"), items: groups.thisWeek },
|
||
{ title: t("sidebar.lastWeek"), items: groups.lastWeek },
|
||
{ title: t("sidebar.thisMonth"), items: groups.thisMonth },
|
||
{ title: t("sidebar.lastMonth"), items: groups.lastMonth },
|
||
{ title: t("sidebar.older"), items: groups.older },
|
||
].filter((g) => g.items.length > 0);
|
||
});
|
||
|
||
const totalInQueue = computed(() => uploadQueue.value.length);
|
||
const completedInQueue = computed(
|
||
() =>
|
||
uploadQueue.value.filter(
|
||
(item) => item.status === "completed" || item.status === "failed"
|
||
).length
|
||
);
|
||
const finishedFilesInQueue = computed(() =>
|
||
uploadQueue.value.filter((item) =>
|
||
["completed", "failed"].includes(item.status)
|
||
)
|
||
);
|
||
const showRecordingControls = computed(() => {
|
||
if (currentView.value !== "recording") {
|
||
return false;
|
||
}
|
||
|
||
const hasCompletedRecording =
|
||
!isRecording.value && !isPaused.value && !!audioBlobURL.value;
|
||
|
||
if (hasCompletedRecording) {
|
||
return false;
|
||
}
|
||
|
||
if (isRecording.value || isPaused.value || isStoppingRecording.value) {
|
||
return true;
|
||
}
|
||
|
||
return (
|
||
!audioBlobURL.value &&
|
||
(activeStreams.value.length > 0 ||
|
||
recordingTime.value > 0 ||
|
||
!!currentDraftId.value)
|
||
);
|
||
});
|
||
|
||
const clearCompletedUploads = () => {
|
||
uploadQueue.value = uploadQueue.value.filter(
|
||
(item) => !["completed", "failed"].includes(item.status)
|
||
);
|
||
};
|
||
|
||
const identifiedSpeakers = computed(() => {
|
||
// Ensure we have a valid recording and transcription
|
||
if (!selectedRecording.value?.transcription) {
|
||
return [];
|
||
}
|
||
|
||
const transcription = selectedRecording.value.transcription;
|
||
let transcriptionData;
|
||
|
||
try {
|
||
transcriptionData = JSON.parse(transcription);
|
||
} catch (e) {
|
||
transcriptionData = null;
|
||
}
|
||
|
||
// Updated to handle new simplified JSON format (array of segments)
|
||
if (transcriptionData && Array.isArray(transcriptionData)) {
|
||
// JSON format - extract speakers in order of appearance
|
||
const speakersInOrder = [];
|
||
const seenSpeakers = new Set();
|
||
transcriptionData.forEach((segment) => {
|
||
if (
|
||
segment.speaker &&
|
||
String(segment.speaker).trim() &&
|
||
!seenSpeakers.has(segment.speaker)
|
||
) {
|
||
seenSpeakers.add(segment.speaker);
|
||
speakersInOrder.push(segment.speaker);
|
||
}
|
||
});
|
||
return speakersInOrder; // Keep order of appearance, don't sort
|
||
} else if (typeof transcription === "string") {
|
||
// Plain text format - find speakers in order of appearance
|
||
const speakerRegex = /\[([^\]]+)\]:/g;
|
||
const speakersInOrder = [];
|
||
const seenSpeakers = new Set();
|
||
let match;
|
||
while ((match = speakerRegex.exec(transcription)) !== null) {
|
||
const speaker = match[1].trim();
|
||
if (speaker && !seenSpeakers.has(speaker)) {
|
||
seenSpeakers.add(speaker);
|
||
speakersInOrder.push(speaker);
|
||
}
|
||
}
|
||
return speakersInOrder; // Keep order of appearance, don't sort
|
||
}
|
||
return [];
|
||
});
|
||
|
||
// identifiedSpeakersInOrder is now just an alias since identifiedSpeakers already preserves order
|
||
const identifiedSpeakersInOrder = computed(() => {
|
||
return identifiedSpeakers.value;
|
||
});
|
||
|
||
const hasSpeakerNames = computed(() => {
|
||
// Check if any speaker has a non-empty name
|
||
return Object.values(speakerMap.value).some(
|
||
(speakerData) => speakerData.name && speakerData.name.trim() !== ""
|
||
);
|
||
});
|
||
|
||
const processedTranscription = computed(() => {
|
||
if (!selectedRecording.value?.transcription) {
|
||
return {
|
||
hasDialogue: false,
|
||
content: "",
|
||
speakers: [],
|
||
simpleSegments: [],
|
||
bubbleRows: [],
|
||
};
|
||
}
|
||
|
||
const transcription = selectedRecording.value.transcription;
|
||
let transcriptionData;
|
||
|
||
try {
|
||
transcriptionData = JSON.parse(transcription);
|
||
} catch (e) {
|
||
transcriptionData = null;
|
||
}
|
||
|
||
// Handle new simplified JSON format (array of segments)
|
||
if (transcriptionData && Array.isArray(transcriptionData)) {
|
||
const wasDiarized = transcriptionData.some(
|
||
(segment) => segment.speaker
|
||
);
|
||
|
||
if (!wasDiarized) {
|
||
const segments = transcriptionData.map((segment) => ({
|
||
sentence: segment.sentence,
|
||
startTime: segment.start_time,
|
||
}));
|
||
return {
|
||
hasDialogue: false,
|
||
isJson: true,
|
||
content: segments.map((s) => s.sentence).join("\n"),
|
||
simpleSegments: segments,
|
||
speakers: [],
|
||
bubbleRows: [],
|
||
};
|
||
}
|
||
|
||
// Extract unique speakers
|
||
const speakers = [
|
||
...new Set(
|
||
transcriptionData
|
||
.map((segment) => segment.speaker)
|
||
.filter(Boolean)
|
||
),
|
||
];
|
||
const speakerColors = {};
|
||
speakers.forEach((speaker, index) => {
|
||
speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
|
||
});
|
||
|
||
const simpleSegments = transcriptionData.map((segment) => ({
|
||
speakerId: segment.speaker,
|
||
speaker: speakerMap.value[segment.speaker]?.name || segment.speaker,
|
||
sentence: segment.sentence,
|
||
startTime: segment.start_time || segment.startTime,
|
||
endTime: segment.end_time || segment.endTime,
|
||
color: speakerColors[segment.speaker] || "speaker-color-1",
|
||
}));
|
||
|
||
const processedSimpleSegments = [];
|
||
let lastSpeakerId = null;
|
||
simpleSegments.forEach((segment) => {
|
||
processedSimpleSegments.push({
|
||
...segment,
|
||
showSpeaker: segment.speakerId !== lastSpeakerId,
|
||
});
|
||
lastSpeakerId = segment.speakerId;
|
||
});
|
||
|
||
const bubbleRows = [];
|
||
let lastBubbleSpeakerId = null;
|
||
simpleSegments.forEach((segment) => {
|
||
if (
|
||
bubbleRows.length === 0 ||
|
||
segment.speakerId !== lastBubbleSpeakerId
|
||
) {
|
||
bubbleRows.push({
|
||
speaker: segment.speaker,
|
||
color: segment.color,
|
||
isMe:
|
||
segment.speaker &&
|
||
typeof segment.speaker === "string" &&
|
||
segment.speaker.toLowerCase().includes("me"),
|
||
bubbles: [],
|
||
});
|
||
lastBubbleSpeakerId = segment.speakerId;
|
||
}
|
||
bubbleRows[bubbleRows.length - 1].bubbles.push({
|
||
sentence: segment.sentence,
|
||
startTime: segment.startTime || segment.start_time,
|
||
color: segment.color,
|
||
});
|
||
});
|
||
|
||
return {
|
||
hasDialogue: true,
|
||
isJson: true,
|
||
segments: simpleSegments,
|
||
simpleSegments: processedSimpleSegments,
|
||
bubbleRows: bubbleRows,
|
||
speakers: speakers.map((speaker) => ({
|
||
name: speakerMap.value[speaker]?.name || speaker,
|
||
color: speakerColors[speaker],
|
||
})),
|
||
};
|
||
} else {
|
||
// Fallback for plain text transcription
|
||
const speakerRegex = /\[([^\]]+)\]:\s*/g;
|
||
const hasDialogue = speakerRegex.test(transcription);
|
||
|
||
if (!hasDialogue) {
|
||
return {
|
||
hasDialogue: false,
|
||
isJson: false,
|
||
content: transcription,
|
||
speakers: [],
|
||
simpleSegments: [],
|
||
bubbleRows: [],
|
||
};
|
||
}
|
||
|
||
speakerRegex.lastIndex = 0;
|
||
const speakers = new Set();
|
||
let match;
|
||
while ((match = speakerRegex.exec(transcription)) !== null) {
|
||
speakers.add(match[1]);
|
||
}
|
||
|
||
const speakerList = Array.from(speakers);
|
||
const speakerColors = {};
|
||
speakerList.forEach((speaker, index) => {
|
||
speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
|
||
});
|
||
|
||
const segments = [];
|
||
const lines = transcription.split("\n");
|
||
let currentSpeakerId = null;
|
||
let currentText = "";
|
||
|
||
for (const line of lines) {
|
||
const speakerMatch = line.match(/^\[([^\]]+)\]:\s*(.*)$/);
|
||
if (speakerMatch) {
|
||
if (currentSpeakerId && currentText.trim()) {
|
||
segments.push({
|
||
speakerId: currentSpeakerId,
|
||
speaker:
|
||
speakerMap.value[currentSpeakerId]?.name ||
|
||
currentSpeakerId,
|
||
sentence: currentText.trim(),
|
||
color: speakerColors[currentSpeakerId] || "speaker-color-1",
|
||
});
|
||
}
|
||
currentSpeakerId = speakerMatch[1];
|
||
currentText = speakerMatch[2];
|
||
} else if (currentSpeakerId && line.trim()) {
|
||
currentText += " " + line.trim();
|
||
} else if (!currentSpeakerId && line.trim()) {
|
||
segments.push({
|
||
speakerId: null,
|
||
speaker: null,
|
||
sentence: line.trim(),
|
||
color: "speaker-color-1",
|
||
});
|
||
}
|
||
}
|
||
|
||
if (currentSpeakerId && currentText.trim()) {
|
||
segments.push({
|
||
speakerId: currentSpeakerId,
|
||
speaker:
|
||
speakerMap.value[currentSpeakerId]?.name || currentSpeakerId,
|
||
sentence: currentText.trim(),
|
||
color: speakerColors[currentSpeakerId] || "speaker-color-1",
|
||
});
|
||
}
|
||
|
||
const simpleSegments = [];
|
||
let lastSpeakerId = null;
|
||
segments.forEach((segment) => {
|
||
simpleSegments.push({
|
||
...segment,
|
||
showSpeaker: segment.speakerId !== lastSpeakerId,
|
||
sentence: segment.sentence || segment.text,
|
||
});
|
||
lastSpeakerId = segment.speakerId;
|
||
});
|
||
|
||
const bubbleRows = [];
|
||
let currentRow = null;
|
||
segments.forEach((segment) => {
|
||
if (!currentRow || currentRow.speakerId !== segment.speakerId) {
|
||
if (currentRow) bubbleRows.push(currentRow);
|
||
currentRow = {
|
||
speakerId: segment.speakerId,
|
||
speaker: segment.speaker,
|
||
color: segment.color,
|
||
bubbles: [],
|
||
isMe:
|
||
segment.speaker &&
|
||
segment.speaker.toLowerCase().includes("me"),
|
||
};
|
||
}
|
||
currentRow.bubbles.push({
|
||
sentence: segment.sentence,
|
||
color: segment.color,
|
||
});
|
||
});
|
||
if (currentRow) bubbleRows.push(currentRow);
|
||
|
||
return {
|
||
hasDialogue: true,
|
||
isJson: false,
|
||
segments: segments,
|
||
simpleSegments: simpleSegments,
|
||
bubbleRows: bubbleRows,
|
||
speakers: speakerList.map((speaker) => ({
|
||
name: speakerMap.value[speaker]?.name || speaker,
|
||
color: speakerColors[speaker] || "speaker-color-1",
|
||
})),
|
||
};
|
||
}
|
||
});
|
||
|
||
//--------------------计算属性与派生展示数据 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 = `https://${wssBaseUrl.SCREEN_PUSH_IP}/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 = `https://${wssBaseUrl.SCREEN_PUSH_IP}/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(
|
||
`https://${wssBaseUrl.SCREEN_PUSH_IP}/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(
|
||
`https://${wssBaseUrl.SCREEN_PUSH_IP}/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(
|
||
`https://${wssBaseUrl.SCREEN_PUSH_IP}/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------------------
|
||
|
||
//------------------- 日期分组与排序工具 start------------------
|
||
// --- Enhanced Date Utility Functions ---
|
||
const getDateForSorting = (recording) => {
|
||
const dateStr =
|
||
sortBy.value === "meeting_date"
|
||
? recording.meeting_date
|
||
: recording.created_at;
|
||
if (!dateStr) return null;
|
||
return new Date(dateStr);
|
||
};
|
||
|
||
const isToday = (date) => {
|
||
const today = new Date();
|
||
return isSameDay(date, today);
|
||
};
|
||
|
||
const isYesterday = (date) => {
|
||
const yesterday = new Date();
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
return isSameDay(date, yesterday);
|
||
};
|
||
|
||
const isThisWeek = (date) => {
|
||
const now = new Date();
|
||
const startOfWeek = new Date(now);
|
||
const day = now.getDay();
|
||
const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
|
||
startOfWeek.setDate(diff);
|
||
startOfWeek.setHours(0, 0, 0, 0);
|
||
|
||
const endOfWeek = new Date(startOfWeek);
|
||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||
endOfWeek.setHours(23, 59, 59, 999);
|
||
|
||
return date >= startOfWeek && date <= endOfWeek;
|
||
};
|
||
|
||
const isLastWeek = (date) => {
|
||
const now = new Date();
|
||
const startOfLastWeek = new Date(now);
|
||
const day = now.getDay();
|
||
const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
|
||
startOfLastWeek.setDate(diff);
|
||
startOfLastWeek.setHours(0, 0, 0, 0);
|
||
|
||
const endOfLastWeek = new Date(startOfLastWeek);
|
||
endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
|
||
endOfLastWeek.setHours(23, 59, 59, 999);
|
||
|
||
return date >= startOfLastWeek && date <= endOfLastWeek;
|
||
};
|
||
|
||
const isThisMonth = (date) => {
|
||
const now = new Date();
|
||
return (
|
||
date.getFullYear() === now.getFullYear() &&
|
||
date.getMonth() === now.getMonth()
|
||
);
|
||
};
|
||
|
||
const isLastMonth = (date) => {
|
||
const now = new Date();
|
||
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||
return (
|
||
date.getFullYear() === lastMonth.getFullYear() &&
|
||
date.getMonth() === lastMonth.getMonth()
|
||
);
|
||
};
|
||
|
||
const isSameDay = (date1, date2) => {
|
||
return (
|
||
date1.getFullYear() === date2.getFullYear() &&
|
||
date1.getMonth() === date2.getMonth() &&
|
||
date1.getDate() === date2.getDate()
|
||
);
|
||
};
|
||
|
||
//--------------------日期分组与排序工具 end------------------
|
||
|
||
//------------------- 录音详情高级操作 start------------------
|
||
const reprocessTranscription = (recordingId) => {
|
||
const recording =
|
||
recordings.value.find((r) => r.id === recordingId) ||
|
||
selectedRecording.value;
|
||
confirmReprocess("transcription", recording);
|
||
};
|
||
|
||
const reprocessSummary = (recordingId) => {
|
||
const recording =
|
||
recordings.value.find((r) => r.id === recordingId) ||
|
||
selectedRecording.value;
|
||
confirmReprocess("summary", recording);
|
||
};
|
||
|
||
const generateSummary = async () => {
|
||
if (!selectedRecording.value) return;
|
||
|
||
try {
|
||
const csrfToken = document
|
||
.querySelector('meta[name="csrf-token"]')
|
||
?.getAttribute("content");
|
||
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${selectedRecording.value.id}/generate_summary`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRFToken": csrfToken,
|
||
},
|
||
}
|
||
);
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "Failed to generate summary");
|
||
}
|
||
|
||
// Update the recording status to show it's being processed
|
||
selectedRecording.value.status = "SUMMARIZING";
|
||
|
||
// Also update in recordings list if it exists
|
||
const recordingInList = recordings.value.find(
|
||
(r) => r.id === selectedRecording.value.id
|
||
);
|
||
if (recordingInList) {
|
||
recordingInList.status = "SUMMARIZING";
|
||
}
|
||
|
||
showToast("Summary generation started", "success");
|
||
} catch (error) {
|
||
console.error("Error generating summary:", error);
|
||
setGlobalError(`Failed to generate summary: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
const resetRecordingStatus = async (recordingId) => {
|
||
const recording = recordings.value.find((r) => r.id === recordingId);
|
||
if (!recording) return;
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${recording.id}/reset_status`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
}
|
||
);
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "Failed to reset status");
|
||
}
|
||
const index = recordings.value.findIndex(
|
||
(r) => r.id === recording.id
|
||
);
|
||
if (index !== -1) {
|
||
recordings.value[index] = data.recording;
|
||
}
|
||
if (selectedRecording.value?.id === recording.id) {
|
||
selectedRecording.value = data.recording;
|
||
}
|
||
showToast("Recording status has been reset.", "fa-check-circle");
|
||
} catch (error) {
|
||
setGlobalError(`Failed to reset status: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
const confirmReprocess = (type, recording) => {
|
||
reprocessType.value = type;
|
||
reprocessRecording.value = recording;
|
||
showReprocessModal.value = true;
|
||
};
|
||
|
||
const openTranscriptionEditor = () => {
|
||
if (processedTranscription.value.isJson) {
|
||
openAsrEditorModal();
|
||
} else {
|
||
openTextEditorModal();
|
||
}
|
||
};
|
||
|
||
const openTextEditorModal = () => {
|
||
if (!selectedRecording.value) return;
|
||
editingTranscriptionContent.value =
|
||
selectedRecording.value.transcription;
|
||
showTextEditorModal.value = true;
|
||
};
|
||
|
||
const closeTextEditorModal = () => {
|
||
showTextEditorModal.value = false;
|
||
editingTranscriptionContent.value = "";
|
||
};
|
||
|
||
const saveTranscription = async () => {
|
||
if (!selectedRecording.value) return;
|
||
await saveTranscriptionContent(editingTranscriptionContent.value);
|
||
closeTextEditorModal();
|
||
};
|
||
|
||
const openAsrEditorModal = async () => {
|
||
if (!selectedRecording.value) return;
|
||
try {
|
||
const segments = JSON.parse(selectedRecording.value.transcription);
|
||
editingSegments.value = segments.map((s, i) => ({
|
||
...s,
|
||
id: i,
|
||
showSuggestions: false,
|
||
filteredSpeakers: [],
|
||
}));
|
||
|
||
// Populate available speakers
|
||
const speakersInTranscript = [
|
||
...new Set(segments.map((s) => s.speaker)),
|
||
];
|
||
const response = await fetch("/tool/speakr/speakers");
|
||
const speakersFromDb = await response.json();
|
||
const speakerNamesFromDb = speakersFromDb.map((s) => s.name);
|
||
availableSpeakers.value = [
|
||
...new Set([...speakersInTranscript, ...speakerNamesFromDb]),
|
||
].sort();
|
||
|
||
showAsrEditorModal.value = true;
|
||
} catch (e) {
|
||
console.error(
|
||
"Could not parse transcription as JSON for ASR editor:",
|
||
e
|
||
);
|
||
setGlobalError(
|
||
"This transcription is not in the correct format for the ASR editor."
|
||
);
|
||
}
|
||
};
|
||
|
||
const closeAsrEditorModal = () => {
|
||
showAsrEditorModal.value = false;
|
||
editingSegments.value = [];
|
||
availableSpeakers.value = [];
|
||
};
|
||
|
||
const saveAsrTranscription = async () => {
|
||
const contentToSave = JSON.stringify(
|
||
editingSegments.value.map(
|
||
({ id, showSuggestions, filteredSpeakers, ...rest }) => rest
|
||
)
|
||
);
|
||
await saveTranscriptionContent(contentToSave);
|
||
closeAsrEditorModal();
|
||
};
|
||
|
||
const adjustTime = (index, field, amount) => {
|
||
if (editingSegments.value[index]) {
|
||
editingSegments.value[index][field] = parseFloat(
|
||
(editingSegments.value[index][field] + amount).toFixed(3)
|
||
);
|
||
}
|
||
};
|
||
|
||
const filterSpeakers = (index) => {
|
||
const segment = editingSegments.value[index];
|
||
if (segment) {
|
||
const query = segment.speaker.toLowerCase();
|
||
segment.filteredSpeakers = availableSpeakers.value.filter((s) =>
|
||
s.toLowerCase().includes(query)
|
||
);
|
||
}
|
||
};
|
||
|
||
const openSpeakerSuggestions = (index) => {
|
||
if (editingSegments.value[index]) {
|
||
editingSegments.value[index].showSuggestions = true;
|
||
filterSpeakers(index);
|
||
}
|
||
};
|
||
|
||
const closeSpeakerSuggestions = (index) => {
|
||
if (editingSegments.value[index]) {
|
||
window.setTimeout(() => {
|
||
if (editingSegments.value[index]) {
|
||
editingSegments.value[index].showSuggestions = false;
|
||
}
|
||
}, 200); // Delay to allow click event to register
|
||
}
|
||
};
|
||
|
||
const selectSpeaker = (index, speaker) => {
|
||
if (editingSegments.value[index]) {
|
||
editingSegments.value[index].speaker = speaker;
|
||
editingSegments.value[index].showSuggestions = false;
|
||
}
|
||
};
|
||
|
||
const addSegment = () => {
|
||
const lastSegment =
|
||
editingSegments.value[editingSegments.value.length - 1];
|
||
editingSegments.value.push({
|
||
id: Date.now(),
|
||
speaker: lastSegment ? lastSegment.speaker : "SPEAKER_00",
|
||
start_time: lastSegment ? lastSegment.end_time : 0,
|
||
end_time: lastSegment ? lastSegment.end_time + 1 : 1,
|
||
sentence: "",
|
||
});
|
||
};
|
||
|
||
const removeSegment = (index) => {
|
||
editingSegments.value.splice(index, 1);
|
||
};
|
||
|
||
const saveTranscriptionContent = async (content) => {
|
||
if (!selectedRecording.value) return;
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${selectedRecording.value.id}/update_transcription`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ transcription: content }),
|
||
}
|
||
);
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(data.error || "Failed to update transcription");
|
||
const index = recordings.value.findIndex(
|
||
(r) => r.id === selectedRecording.value.id
|
||
);
|
||
if (index !== -1) {
|
||
recordings.value[index] = data.recording;
|
||
}
|
||
selectedRecording.value = data.recording;
|
||
showToast("Transcription updated successfully!", "fa-check-circle");
|
||
} catch (error) {
|
||
console.error("Save Transcription Error:", error);
|
||
setGlobalError(`Failed to save transcription: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
const cancelReprocess = () => {
|
||
showReprocessModal.value = false;
|
||
reprocessType.value = null;
|
||
reprocessRecording.value = null;
|
||
};
|
||
|
||
const executeReprocess = async () => {
|
||
if (!reprocessRecording.value || !reprocessType.value) return;
|
||
|
||
const recordingId = reprocessRecording.value.id;
|
||
const type = reprocessType.value;
|
||
|
||
// Close the modal first
|
||
cancelReprocess();
|
||
|
||
if (type === "transcription") {
|
||
await performReprocessTranscription(
|
||
recordingId,
|
||
asrReprocessOptions.language,
|
||
asrReprocessOptions.min_speakers,
|
||
asrReprocessOptions.max_speakers
|
||
);
|
||
} else if (type === "summary") {
|
||
await performReprocessSummary(recordingId);
|
||
}
|
||
};
|
||
|
||
const performReprocessTranscription = async (
|
||
recordingId,
|
||
language,
|
||
minSpeakers,
|
||
maxSpeakers
|
||
) => {
|
||
if (!recordingId) {
|
||
setGlobalError("No recording ID provided for reprocessing.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const payload = {
|
||
language: language,
|
||
min_speakers: minSpeakers,
|
||
max_speakers: maxSpeakers,
|
||
};
|
||
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${recordingId}/reprocess_transcription`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
}
|
||
);
|
||
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(
|
||
data.error || "Failed to start transcription reprocessing"
|
||
);
|
||
|
||
// Ensure the recording status is properly set to PROCESSING
|
||
if (data.recording && data.recording.status !== "PROCESSING") {
|
||
console.warn(
|
||
`Warning: Reprocess transcription returned unexpected status: ${data.recording.status}`
|
||
);
|
||
data.recording.status = "PROCESSING";
|
||
}
|
||
|
||
// Update the recording in the UI
|
||
const index = recordings.value.findIndex((r) => r.id === recordingId);
|
||
if (index !== -1) {
|
||
recordings.value[index] = data.recording;
|
||
}
|
||
|
||
// Update selected recording if it's the one being reprocessed
|
||
if (selectedRecording.value?.id === recordingId) {
|
||
selectedRecording.value = data.recording;
|
||
}
|
||
|
||
showToast("Transcription reprocessing started", "fa-sync-alt");
|
||
|
||
// Switch to Summary tab to show progress
|
||
selectedTab.value = "summary";
|
||
|
||
// Show progress modal for reprocessing
|
||
showProgressModalForReprocessing(recordingId, "transcription");
|
||
|
||
// Start polling for status updates
|
||
startReprocessingPoll(recordingId);
|
||
} catch (error) {
|
||
console.error("Reprocess Transcription Error:", error);
|
||
setGlobalError(
|
||
`Failed to start transcription reprocessing: ${error.message}`
|
||
);
|
||
}
|
||
};
|
||
|
||
const performReprocessSummary = async (recordingId) => {
|
||
if (!recordingId) {
|
||
setGlobalError("No recording ID provided for reprocessing.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${recordingId}/reprocess_summary`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
}
|
||
);
|
||
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(
|
||
data.error || "Failed to start summary reprocessing"
|
||
);
|
||
|
||
// Ensure the recording status is properly set to SUMMARIZING
|
||
if (data.recording && data.recording.status !== "SUMMARIZING") {
|
||
console.warn(
|
||
`Warning: Reprocess summary returned unexpected status: ${data.recording.status}`
|
||
);
|
||
data.recording.status = "SUMMARIZING";
|
||
}
|
||
|
||
// Update the recording in the UI
|
||
const index = recordings.value.findIndex((r) => r.id === recordingId);
|
||
if (index !== -1) {
|
||
recordings.value[index] = data.recording;
|
||
}
|
||
|
||
// Update selected recording if it's the one being reprocessed
|
||
if (selectedRecording.value?.id === recordingId) {
|
||
selectedRecording.value = data.recording;
|
||
}
|
||
|
||
showToast("Summary reprocessing started", "fa-sync-alt");
|
||
|
||
// Switch to Summary tab to show progress
|
||
selectedTab.value = "summary";
|
||
|
||
// Show progress modal for reprocessing
|
||
showProgressModalForReprocessing(recordingId, "summary");
|
||
|
||
// Start polling for status updates
|
||
startReprocessingPoll(recordingId);
|
||
} catch (error) {
|
||
console.error("Reprocess Summary Error:", error);
|
||
setGlobalError(
|
||
`Failed to start summary reprocessing: ${error.message}`
|
||
);
|
||
}
|
||
};
|
||
|
||
// Show progress modal for reprocessing operations
|
||
const showProgressModalForReprocessing = (recordingId, type) => {
|
||
const recording = recordings.value.find((r) => r.id === recordingId);
|
||
if (!recording) return;
|
||
|
||
// Create a mock file item for the progress modal
|
||
const reprocessItem = {
|
||
file: {
|
||
name: recording.title || `Recording ${recordingId}`,
|
||
size: recording.file_size || 0,
|
||
},
|
||
status: "pending",
|
||
recordingId: recordingId,
|
||
clientId: `reprocess-${type}-${recordingId}-${Date.now()}`,
|
||
error: null,
|
||
isReprocessing: true,
|
||
reprocessType: type,
|
||
};
|
||
|
||
// Add to upload queue for progress tracking
|
||
uploadQueue.value.unshift(reprocessItem);
|
||
|
||
// Show progress modal
|
||
progressPopupMinimized.value = false;
|
||
progressPopupClosed.value = false;
|
||
|
||
// Set as currently processing file
|
||
currentlyProcessingFile.value = reprocessItem;
|
||
processingProgress.value = 10;
|
||
processingMessage.value =
|
||
type === "transcription"
|
||
? "Starting transcription reprocessing..."
|
||
: "Starting summary reprocessing...";
|
||
};
|
||
|
||
// Polling for reprocessing status updates
|
||
const reprocessingPolls = ref(new Map()); // Track active polls by recording ID
|
||
|
||
const startReprocessingPoll = (recordingId) => {
|
||
// Clear any existing poll for this recording
|
||
if (reprocessingPolls.value.has(recordingId)) {
|
||
clearInterval(reprocessingPolls.value.get(recordingId));
|
||
}
|
||
|
||
// console.log(`Starting reprocessing poll for recording ${recordingId}`);
|
||
|
||
const pollInterval = setInterval(async () => {
|
||
try {
|
||
const response = await fetch(`/tool/speakr/status/${recordingId}`);
|
||
if (!response.ok) {
|
||
console.error(`Status check failed for recording ${recordingId}`);
|
||
stopReprocessingPoll(recordingId);
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Update the recording in the UI
|
||
const index = recordings.value.findIndex(
|
||
(r) => r.id === recordingId
|
||
);
|
||
if (index !== -1) {
|
||
recordings.value[index] = data;
|
||
}
|
||
|
||
// Update selected recording if it's the one being reprocessed
|
||
if (selectedRecording.value?.id === recordingId) {
|
||
selectedRecording.value = data;
|
||
}
|
||
|
||
const queueItem = uploadQueue.value.find(
|
||
(item) => item.recordingId === recordingId
|
||
);
|
||
|
||
// Update the item's status and name for intermediate states
|
||
if (queueItem) {
|
||
queueItem.status = data.status; // e.g., 'PROCESSING', 'SUMMARIZING'
|
||
queueItem.file.name = data.title || data.original_filename;
|
||
}
|
||
|
||
// Update progress modal if this is the currently processing item
|
||
if (
|
||
queueItem &&
|
||
currentlyProcessingFile.value?.clientId === queueItem.clientId
|
||
) {
|
||
updateReprocessingProgress(data.status, queueItem);
|
||
}
|
||
|
||
// Stop polling if processing is complete
|
||
if (data.status === "COMPLETED" || data.status === "FAILED") {
|
||
console.log(
|
||
`Reprocessing ${data.status.toLowerCase()} for recording ${recordingId}`
|
||
);
|
||
stopReprocessingPoll(recordingId);
|
||
|
||
// Update queue item status to final lowercase state
|
||
if (queueItem) {
|
||
queueItem.status =
|
||
data.status === "COMPLETED" ? "completed" : "failed";
|
||
if (data.status === "FAILED") {
|
||
queueItem.error = data.error_message || "Reprocessing failed";
|
||
}
|
||
}
|
||
|
||
// Clear current processing if this was the active item
|
||
if (currentlyProcessingFile.value?.recordingId === recordingId) {
|
||
resetCurrentFileProcessingState();
|
||
}
|
||
|
||
if (data.status === "COMPLETED") {
|
||
showToast(
|
||
"Reprocessing completed successfully",
|
||
"fa-check-circle"
|
||
);
|
||
} else {
|
||
showToast("Reprocessing failed", "fa-exclamation-circle");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(
|
||
`Error polling status for recording ${recordingId}:`,
|
||
error
|
||
);
|
||
stopReprocessingPoll(recordingId);
|
||
}
|
||
}, 3000);
|
||
|
||
reprocessingPolls.value.set(recordingId, pollInterval);
|
||
};
|
||
|
||
const updateReprocessingProgress = (status, queueItem) => {
|
||
switch (status) {
|
||
case "PENDING":
|
||
processingProgress.value = 20;
|
||
processingMessage.value = `Waiting to start ${queueItem.reprocessType} reprocessing...`;
|
||
break;
|
||
case "PROCESSING":
|
||
processingProgress.value = Math.round(
|
||
Math.min(70, processingProgress.value + Math.random() * 10)
|
||
);
|
||
processingMessage.value =
|
||
queueItem.reprocessType === "transcription"
|
||
? "Reprocessing transcription..."
|
||
: "Processing audio...";
|
||
break;
|
||
case "SUMMARIZING":
|
||
processingProgress.value = Math.round(
|
||
Math.min(90, processingProgress.value + Math.random() * 10)
|
||
);
|
||
processingMessage.value =
|
||
queueItem.reprocessType === "summary"
|
||
? "Regenerating summary..."
|
||
: "Generating title and summary...";
|
||
break;
|
||
case "COMPLETED":
|
||
processingProgress.value = 100;
|
||
processingMessage.value = "Reprocessing completed!";
|
||
break;
|
||
case "FAILED":
|
||
processingProgress.value = 100;
|
||
processingMessage.value = "Reprocessing failed.";
|
||
break;
|
||
default:
|
||
processingProgress.value = 15;
|
||
processingMessage.value = "Starting reprocessing...";
|
||
}
|
||
};
|
||
|
||
const stopReprocessingPoll = (recordingId) => {
|
||
if (reprocessingPolls.value.has(recordingId)) {
|
||
clearInterval(reprocessingPolls.value.get(recordingId));
|
||
reprocessingPolls.value.delete(recordingId);
|
||
console.log(`Stopped reprocessing poll for recording ${recordingId}`);
|
||
}
|
||
};
|
||
|
||
const confirmReset = (recording) => {
|
||
recordingToReset.value = recording;
|
||
showResetModal.value = true;
|
||
};
|
||
|
||
const cancelReset = () => {
|
||
showResetModal.value = false;
|
||
recordingToReset.value = null;
|
||
};
|
||
|
||
const executeReset = async () => {
|
||
if (!recordingToReset.value) return;
|
||
const recordingId = recordingToReset.value.id;
|
||
|
||
// Close the modal first
|
||
cancelReset();
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${recordingId}/reset_status`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
}
|
||
);
|
||
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(data.error || "Failed to reset status");
|
||
|
||
// Update the recording in the UI
|
||
const index = recordings.value.findIndex((r) => r.id === recordingId);
|
||
if (index !== -1) {
|
||
recordings.value[index] = data.recording;
|
||
}
|
||
|
||
// Update selected recording if it's the one being reset
|
||
if (selectedRecording.value?.id === recordingId) {
|
||
selectedRecording.value = data.recording;
|
||
}
|
||
|
||
showToast("Recording status has been reset.", "fa-check-circle");
|
||
} catch (error) {
|
||
console.error("Reset Status Error:", error);
|
||
setGlobalError(`Failed to reset status: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
const searchSpeakers = async (query, speakerId) => {
|
||
if (!query || query.length < 2) {
|
||
speakerSuggestions.value[speakerId] = [];
|
||
return;
|
||
}
|
||
|
||
loadingSuggestions.value[speakerId] = true;
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/speakers/search?q=${encodeURIComponent(query)}`
|
||
);
|
||
if (!response.ok) throw new Error("Failed to search speakers");
|
||
|
||
const speakers = await response.json();
|
||
speakerSuggestions.value[speakerId] = speakers;
|
||
} catch (error) {
|
||
console.error("Error searching speakers:", error);
|
||
speakerSuggestions.value[speakerId] = [];
|
||
} finally {
|
||
loadingSuggestions.value[speakerId] = false;
|
||
}
|
||
};
|
||
|
||
const selectSpeakerSuggestion = (speakerId, suggestion) => {
|
||
if (speakerMap.value[speakerId]) {
|
||
speakerMap.value[speakerId].name = suggestion.name;
|
||
speakerSuggestions.value[speakerId] = [];
|
||
}
|
||
};
|
||
|
||
const closeSpeakerSuggestionsOnClick = (event) => {
|
||
// Check if the click was on an input field or dropdown
|
||
const clickedInput = event.target.closest('input[type="text"]');
|
||
const clickedDropdown = event.target.closest(".absolute.z-10");
|
||
|
||
// If not clicking on input or dropdown, close all suggestions
|
||
if (!clickedInput && !clickedDropdown) {
|
||
Object.keys(speakerSuggestions.value).forEach((speakerId) => {
|
||
speakerSuggestions.value[speakerId] = [];
|
||
});
|
||
}
|
||
};
|
||
|
||
// Create a mapping for display-friendly speaker IDs
|
||
const speakerDisplayMap = ref({});
|
||
const modalSpeakers = ref([]);
|
||
const is_final = ref(false);
|
||
const openSpeakerModal = () => {
|
||
// Clear any existing speaker map data first
|
||
speakerMap.value = {};
|
||
speakerDisplayMap.value = {};
|
||
|
||
// Get the same speaker order used in processedTranscription
|
||
const transcription = selectedRecording.value?.transcription;
|
||
let speakers = [];
|
||
|
||
if (transcription) {
|
||
try {
|
||
const transcriptionData = JSON.parse(transcription);
|
||
if (transcriptionData && Array.isArray(transcriptionData)) {
|
||
// Use the exact same logic as processedTranscription to get speakers
|
||
speakers = [
|
||
...new Set(
|
||
transcriptionData
|
||
.map((segment) => segment.speaker)
|
||
.filter(Boolean)
|
||
),
|
||
];
|
||
}
|
||
} catch (e) {
|
||
// Fall back to identifiedSpeakers if JSON parsing fails
|
||
speakers = identifiedSpeakers.value;
|
||
}
|
||
}
|
||
|
||
// Set modalSpeakers for the template to use
|
||
modalSpeakers.value = speakers;
|
||
|
||
// Initialize speaker map with the same order and colors as the transcript
|
||
speakerMap.value = speakers.reduce((acc, speaker, index) => {
|
||
acc[speaker] = {
|
||
name: "",
|
||
isMe: false,
|
||
color: `speaker-color-${(index % 8) + 1}`, // Same color assignment as processedTranscription
|
||
};
|
||
// Keep the original speaker ID for display
|
||
speakerDisplayMap.value[speaker] = speaker;
|
||
return acc;
|
||
}, {});
|
||
|
||
highlightedSpeaker.value = null;
|
||
speakerSuggestions.value = {};
|
||
loadingSuggestions.value = {};
|
||
isAutoIdentifying.value = false;
|
||
showSpeakerModal.value = true;
|
||
};
|
||
|
||
const closeSpeakerModal = () => {
|
||
showSpeakerModal.value = false;
|
||
highlightedSpeaker.value = null;
|
||
// Clear the speaker map to prevent stale data from persisting
|
||
speakerMap.value = {};
|
||
speakerSuggestions.value = {};
|
||
loadingSuggestions.value = {};
|
||
|
||
// Clean up click handler if it exists
|
||
if (window.speakerModalClickHandler) {
|
||
const modalContent = document.querySelector(".modal-content");
|
||
if (modalContent) {
|
||
modalContent.removeEventListener(
|
||
"click",
|
||
window.speakerModalClickHandler
|
||
);
|
||
}
|
||
delete window.speakerModalClickHandler;
|
||
}
|
||
};
|
||
|
||
const saveSpeakerNames = async () => {
|
||
if (!selectedRecording.value) return;
|
||
|
||
// Create a filtered speaker map that excludes entries with blank names
|
||
const filteredSpeakerMap = Object.entries(speakerMap.value).reduce(
|
||
(acc, [speakerId, speakerData]) => {
|
||
if (speakerData.name && speakerData.name.trim() !== "") {
|
||
acc[speakerId] = speakerData;
|
||
}
|
||
return acc;
|
||
},
|
||
{}
|
||
);
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${selectedRecording.value.id}/update_speakers`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
speaker_map: filteredSpeakerMap, // Send the filtered map
|
||
regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value,
|
||
}),
|
||
}
|
||
);
|
||
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(data.error || "Failed to update speakers");
|
||
|
||
// On success, close the modal and clear the speakerMap state *before*
|
||
// updating the recording data. This prevents a race condition where the
|
||
// view could re-render using the new data but the old, lingering speakerMap.
|
||
closeSpeakerModal();
|
||
|
||
// The backend returns the fully updated recording object.
|
||
// We can directly update our local state with this fresh data.
|
||
const index = recordings.value.findIndex(
|
||
(r) => r.id === selectedRecording.value.id
|
||
);
|
||
if (index !== -1) {
|
||
recordings.value[index] = data.recording;
|
||
}
|
||
selectedRecording.value = data.recording;
|
||
|
||
showToast("Speaker names updated successfully!", "fa-check-circle");
|
||
|
||
// If a summary regeneration was requested, start polling for its status.
|
||
if (regenerateSummaryAfterSpeakerUpdate.value) {
|
||
startReprocessingPoll(selectedRecording.value.id);
|
||
}
|
||
} catch (error) {
|
||
console.error("Save Speaker Names Error:", error);
|
||
setGlobalError(`Failed to save speaker names: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
// Speaker group navigation state
|
||
const currentSpeakerGroupIndex = ref(-1);
|
||
const speakerGroups = ref([]);
|
||
|
||
const findSpeakerGroups = (speakerId) => {
|
||
if (!speakerId) return [];
|
||
|
||
const groups = [];
|
||
const modalTranscript = document.querySelector(
|
||
"div.speaker-modal-transcript"
|
||
);
|
||
const mainTranscript = document.querySelector(
|
||
".transcription-simple-view, .transcription-with-speakers, .transcription-content"
|
||
);
|
||
const transcriptContainer = modalTranscript || mainTranscript;
|
||
|
||
if (!transcriptContainer) return [];
|
||
|
||
// For JSON-based transcripts with segments
|
||
const allSegments =
|
||
transcriptContainer.querySelectorAll(".speaker-segment");
|
||
if (allSegments.length > 0) {
|
||
let currentGroup = null;
|
||
let lastSpeakerId = null;
|
||
|
||
allSegments.forEach((segment) => {
|
||
const speakerTag = segment.querySelector("[data-speaker-id]");
|
||
const segmentSpeakerId = speakerTag?.dataset.speakerId;
|
||
|
||
if (segmentSpeakerId === speakerId) {
|
||
// If this is a new group (not consecutive with previous)
|
||
if (lastSpeakerId !== speakerId) {
|
||
currentGroup = {
|
||
startElement: segment,
|
||
elements: [segment],
|
||
};
|
||
groups.push(currentGroup);
|
||
} else if (currentGroup) {
|
||
// Add to existing group
|
||
currentGroup.elements.push(segment);
|
||
}
|
||
}
|
||
lastSpeakerId = segmentSpeakerId;
|
||
});
|
||
} else {
|
||
// For plain text transcripts with speaker tags
|
||
const allTags =
|
||
transcriptContainer.querySelectorAll("[data-speaker-id]");
|
||
let currentGroup = null;
|
||
|
||
allTags.forEach((tag) => {
|
||
if (tag.dataset.speakerId === speakerId) {
|
||
// Find the parent element that contains this speaker's content
|
||
const parentSegment =
|
||
tag.closest(".speaker-segment") || tag.parentElement;
|
||
|
||
if (
|
||
!currentGroup ||
|
||
!currentGroup.lastElement ||
|
||
!parentSegment.previousElementSibling ||
|
||
parentSegment.previousElementSibling !==
|
||
currentGroup.lastElement
|
||
) {
|
||
// Start a new group
|
||
currentGroup = {
|
||
startElement: parentSegment,
|
||
elements: [parentSegment],
|
||
lastElement: parentSegment,
|
||
};
|
||
groups.push(currentGroup);
|
||
} else {
|
||
// Continue the group
|
||
currentGroup.elements.push(parentSegment);
|
||
currentGroup.lastElement = parentSegment;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
return groups;
|
||
};
|
||
|
||
const highlightSpeakerInTranscript = (speakerId) => {
|
||
highlightedSpeaker.value = speakerId;
|
||
|
||
if (speakerId) {
|
||
// Find all speaker groups for navigation
|
||
speakerGroups.value = findSpeakerGroups(speakerId);
|
||
currentSpeakerGroupIndex.value = 0;
|
||
|
||
// Scroll to the first group
|
||
if (speakerGroups.value.length > 0) {
|
||
nextTick(() => {
|
||
const firstGroup = speakerGroups.value[0];
|
||
if (firstGroup && firstGroup.startElement) {
|
||
firstGroup.startElement.scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "center",
|
||
});
|
||
}
|
||
});
|
||
}
|
||
} else {
|
||
speakerGroups.value = [];
|
||
currentSpeakerGroupIndex.value = -1;
|
||
}
|
||
};
|
||
|
||
const navigateToNextSpeakerGroup = () => {
|
||
if (speakerGroups.value.length === 0) return;
|
||
|
||
// Don't reset the speaker groups, just update the index
|
||
currentSpeakerGroupIndex.value =
|
||
(currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length;
|
||
const group = speakerGroups.value[currentSpeakerGroupIndex.value];
|
||
if (group && group.startElement) {
|
||
group.startElement.scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "center",
|
||
});
|
||
}
|
||
};
|
||
|
||
const navigateToPrevSpeakerGroup = () => {
|
||
if (speakerGroups.value.length === 0) return;
|
||
|
||
// Don't reset the speaker groups, just update the index
|
||
currentSpeakerGroupIndex.value =
|
||
currentSpeakerGroupIndex.value <= 0
|
||
? speakerGroups.value.length - 1
|
||
: currentSpeakerGroupIndex.value - 1;
|
||
const group = speakerGroups.value[currentSpeakerGroupIndex.value];
|
||
if (group && group.startElement) {
|
||
group.startElement.scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "center",
|
||
});
|
||
}
|
||
};
|
||
|
||
// Enhanced speaker highlighting with focus/blur events for text inputs
|
||
const focusSpeaker = (speakerId) => {
|
||
// Set this as the active speaker input
|
||
activeSpeakerInput.value = speakerId;
|
||
// Only highlight if not already highlighted (to preserve navigation state)
|
||
if (highlightedSpeaker.value !== speakerId) {
|
||
highlightSpeakerInTranscript(speakerId);
|
||
}
|
||
};
|
||
|
||
const blurSpeaker = () => {
|
||
// Clear the active speaker input after a delay to allow clicking on suggestions
|
||
setTimeout(() => {
|
||
activeSpeakerInput.value = null;
|
||
speakerSuggestions.value = {};
|
||
}, 200);
|
||
clearSpeakerHighlight();
|
||
};
|
||
|
||
const clearSpeakerHighlight = () => {
|
||
highlightedSpeaker.value = null;
|
||
};
|
||
|
||
const autoIdentifySpeakers = async () => {
|
||
if (!selectedRecording.value) {
|
||
showToast("No recording selected.", "fa-exclamation-circle");
|
||
return;
|
||
}
|
||
|
||
isAutoIdentifying.value = true;
|
||
showToast("Starting automatic speaker identification...", "fa-magic");
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${selectedRecording.value.id}/auto_identify_speakers`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
current_speaker_map: speakerMap.value,
|
||
}),
|
||
}
|
||
);
|
||
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
data.error || "Unknown error occurred during auto-identification."
|
||
);
|
||
}
|
||
|
||
// Check if there's a message (e.g., all speakers already identified)
|
||
if (data.message) {
|
||
showToast(data.message, "fa-info-circle");
|
||
return;
|
||
}
|
||
|
||
// Update speakerMap with the identified names (only for unidentified speakers)
|
||
let identifiedCount = 0;
|
||
for (const speakerId in data.speaker_map) {
|
||
const identifiedName = data.speaker_map[speakerId];
|
||
if (
|
||
speakerMap.value[speakerId] &&
|
||
identifiedName &&
|
||
identifiedName.trim() !== ""
|
||
) {
|
||
speakerMap.value[speakerId].name = identifiedName;
|
||
identifiedCount++;
|
||
}
|
||
}
|
||
|
||
if (identifiedCount > 0) {
|
||
showToast(
|
||
`${identifiedCount} speaker(s) identified successfully!`,
|
||
"fa-check-circle"
|
||
);
|
||
} else {
|
||
showToast(
|
||
"No speakers could be identified from the context.",
|
||
"fa-info-circle"
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error("Auto Identify Speakers Error:", error);
|
||
showToast(`Error: ${error.message}`, "fa-exclamation-circle", 5000);
|
||
} finally {
|
||
isAutoIdentifying.value = false;
|
||
}
|
||
};
|
||
|
||
const toggleInbox = async (recording) => {
|
||
if (!recording || !recording.id) return;
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${recording.id}/toggle_inbox`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
}
|
||
);
|
||
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(data.error || "Failed to toggle inbox status");
|
||
|
||
// Update the recording in the UI
|
||
recording.is_inbox = data.is_inbox;
|
||
|
||
// Update in the recordings list
|
||
const index = recordings.value.findIndex(
|
||
(r) => r.id === recording.id
|
||
);
|
||
if (index !== -1) {
|
||
recordings.value[index].is_inbox = data.is_inbox;
|
||
}
|
||
|
||
showToast(
|
||
`Recording ${data.is_inbox ? "moved to inbox" : "marked as read"}`
|
||
);
|
||
} catch (error) {
|
||
console.error("Toggle Inbox Error:", error);
|
||
setGlobalError(`Failed to toggle inbox status: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
// Toggle highlighted status
|
||
const toggleHighlight = async (recording) => {
|
||
if (!recording || !recording.id) return;
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/tool/speakr/recording/${recording.id}/toggle_highlight`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
}
|
||
);
|
||
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(
|
||
data.error || "Failed to toggle highlighted status"
|
||
);
|
||
|
||
// Update the recording in the UI
|
||
recording.is_highlighted = data.is_highlighted;
|
||
|
||
// Update in the recordings list
|
||
const index = recordings.value.findIndex(
|
||
(r) => r.id === recording.id
|
||
);
|
||
if (index !== -1) {
|
||
recordings.value[index].is_highlighted = data.is_highlighted;
|
||
}
|
||
|
||
showToast(
|
||
`Recording ${data.is_highlighted ? "highlighted" : "unhighlighted"}`
|
||
);
|
||
} catch (error) {
|
||
console.error("Toggle Highlight Error:", error);
|
||
setGlobalError(
|
||
`Failed to toggle highlighted status: ${error.message}`
|
||
);
|
||
}
|
||
};
|
||
|
||
//--------------------录音详情高级操作 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 ---
|
||
const toggleSidebar = () => {
|
||
isSidebarCollapsed.value = !isSidebarCollapsed.value;
|
||
};
|
||
|
||
//--------------------侧边栏与视图切换控制 end------------------
|
||
|
||
//------------------- 页面视图管理 start------------------
|
||
// --- View Management ---
|
||
const switchToUploadView = async () => {
|
||
if (currentView.value === "recording" && isRec === true) {
|
||
const confirmed = await confirmStopRecording();
|
||
if (!confirmed) return;
|
||
|
||
currentView.value = "upload";
|
||
selectedRecording.value = null;
|
||
if (isMobileScreen.value) {
|
||
isSidebarCollapsed.value = true;
|
||
}
|
||
} else {
|
||
currentView.value = "upload";
|
||
selectedRecording.value = null;
|
||
if (isMobileScreen.value) {
|
||
isSidebarCollapsed.value = true;
|
||
}
|
||
}
|
||
};
|
||
|
||
const selectRecording = async (recording) => {
|
||
if (currentView.value === "recording" && isRecording.value) {
|
||
// If we are in the middle of a recording, don't switch views
|
||
setGlobalError(
|
||
"Please stop the current recording before selecting another one."
|
||
);
|
||
return;
|
||
}
|
||
selectedRecording.value = recording;
|
||
|
||
if (currentView.value === "recording" && isRec === true) {
|
||
const confirmed = await confirmStopRecording();
|
||
if (!confirmed) return;
|
||
if (currentView.value === "recording" && isRec === true) {
|
||
const confirmed = await confirmStopRecording();
|
||
if (!confirmed) return;
|
||
|
||
currentView.value = "detail";
|
||
} else {
|
||
currentView.value = "detail";
|
||
}
|
||
if (recording && recording.id) {
|
||
localStorage.setItem("lastSelectedRecordingId", recording.id);
|
||
} else {
|
||
localStorage.removeItem("lastSelectedRecordingId");
|
||
}
|
||
if (isMobileScreen.value) {
|
||
isSidebarCollapsed.value = true;
|
||
}
|
||
} else {
|
||
if (currentView.value === "recording" && isRec === true) {
|
||
const confirmed = await confirmStopRecording();
|
||
if (!confirmed) return;
|
||
|
||
currentView.value = "detail";
|
||
} else {
|
||
currentView.value = "detail";
|
||
}
|
||
if (recording && recording.id) {
|
||
localStorage.setItem("lastSelectedRecordingId", recording.id);
|
||
} else {
|
||
localStorage.removeItem("lastSelectedRecordingId");
|
||
}
|
||
if (isMobileScreen.value) {
|
||
isSidebarCollapsed.value = true;
|
||
}
|
||
}
|
||
};
|
||
|
||
//--------------------页面视图管理 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 resetCurrentFileProcessingState = () => {
|
||
if (pollInterval.value) clearInterval(pollInterval.value);
|
||
pollInterval.value = null;
|
||
currentlyProcessingFile.value = null;
|
||
processingProgress.value = 0;
|
||
processingMessage.value = "";
|
||
};
|
||
|
||
const startProcessingQueue = async () => {
|
||
console.log("Attempting to start processing queue...");
|
||
if (isProcessingActive.value) {
|
||
console.log("Queue processor already active.");
|
||
return;
|
||
}
|
||
|
||
isProcessingActive.value = true;
|
||
resetCurrentFileProcessingState();
|
||
|
||
const nextFileItem = uploadQueue.value.find(
|
||
(item) => item.status === "queued"
|
||
);
|
||
|
||
if (nextFileItem) {
|
||
console.log(
|
||
`Processing next file: ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId})`
|
||
);
|
||
currentlyProcessingFile.value = nextFileItem;
|
||
|
||
// Check if this is a "reload" item (existing recording being tracked)
|
||
if (nextFileItem.clientId.startsWith("reload-")) {
|
||
// Skip upload, go directly to polling existing recording
|
||
console.log(
|
||
`Skipping upload for existing recording: ${nextFileItem.recordingId}`
|
||
);
|
||
nextFileItem.status = "processing";
|
||
startStatusPolling(nextFileItem, nextFileItem.recordingId);
|
||
return;
|
||
}
|
||
|
||
nextFileItem.status = "uploading";
|
||
processingMessage.value = "Preparing upload...";
|
||
processingProgress.value = 5;
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", nextFileItem.file);
|
||
if (nextFileItem.notes) {
|
||
formData.append("notes", nextFileItem.notes);
|
||
}
|
||
|
||
// Add tags if selected (multiple tags)
|
||
// Use tags from the queue item if available, otherwise use global selectedTagIds
|
||
const tagsToUse = nextFileItem.tags || selectedTags.value || [];
|
||
tagsToUse.forEach((tag, index) => {
|
||
const tagId = tag.id || tag; // Handle both tag objects and tag IDs
|
||
formData.append(`tag_ids[${index}]`, tagId);
|
||
});
|
||
|
||
// Add ASR advanced options if ASR endpoint is enabled
|
||
if (useAsrEndpoint.value) {
|
||
// Use ASR options from the queue item if available, otherwise use global values
|
||
const asrOpts = nextFileItem.asrOptions || {};
|
||
const language = asrOpts.language || uploadLanguage.value;
|
||
const minSpeakers =
|
||
asrOpts.min_speakers || uploadMinSpeakers.value;
|
||
const maxSpeakers =
|
||
asrOpts.max_speakers || uploadMaxSpeakers.value;
|
||
|
||
if (language) {
|
||
formData.append("language", language);
|
||
}
|
||
// Only send speaker limits if they're actually set
|
||
if (minSpeakers && minSpeakers !== "") {
|
||
formData.append("min_speakers", minSpeakers.toString());
|
||
}
|
||
if (maxSpeakers && maxSpeakers !== "") {
|
||
formData.append("max_speakers", maxSpeakers.toString());
|
||
}
|
||
}
|
||
|
||
processingMessage.value = "Uploading file...";
|
||
processingProgress.value = 10;
|
||
|
||
const response = await fetch("/tool/speakr/upload", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
let errorMsg =
|
||
data.error || `Upload failed with status ${response.status}`;
|
||
if (response.status === 413)
|
||
errorMsg =
|
||
data.error ||
|
||
`File too large. Max: ${
|
||
data.max_size_mb?.toFixed(0) || maxFileSizeMB.value
|
||
} MB.`;
|
||
throw new Error(errorMsg);
|
||
}
|
||
|
||
if (response.status === 202 && data.id) {
|
||
console.log(
|
||
`File ${nextFileItem.file.name} uploaded. Recording ID: ${data.id}. Starting status poll.`
|
||
);
|
||
nextFileItem.status = "pending";
|
||
nextFileItem.recordingId = data.id;
|
||
processingMessage.value =
|
||
"Upload complete. Waiting for processing...";
|
||
processingProgress.value = 30;
|
||
|
||
recordings.value.unshift(data);
|
||
totalRecordings.value++; // Update total count
|
||
pollProcessingStatus(nextFileItem);
|
||
} else {
|
||
throw new Error(
|
||
"Unexpected success response from server after upload."
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error(
|
||
`Upload/Processing Error for ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId}):`,
|
||
error
|
||
);
|
||
nextFileItem.status = "failed";
|
||
nextFileItem.error = error.message;
|
||
const failedRecordIndex = recordings.value.findIndex(
|
||
(r) => r.id === nextFileItem.recordingId
|
||
);
|
||
if (failedRecordIndex !== -1) {
|
||
recordings.value[failedRecordIndex].status = "FAILED";
|
||
recordings.value[
|
||
failedRecordIndex
|
||
].transcription = `Upload/Processing failed: ${error.message}`;
|
||
} else {
|
||
setGlobalError(
|
||
`Failed to process "${nextFileItem.file.name}": ${error.message}`
|
||
);
|
||
}
|
||
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
await nextTick();
|
||
startProcessingQueue();
|
||
}
|
||
} else {
|
||
console.log("Upload queue is empty or no files are queued.");
|
||
isProcessingActive.value = false;
|
||
}
|
||
};
|
||
|
||
const startStatusPolling = (fileItem, recordingId) => {
|
||
fileItem.recordingId = recordingId;
|
||
pollProcessingStatus(fileItem);
|
||
};
|
||
|
||
const pollProcessingStatus = (fileItem) => {
|
||
if (pollInterval.value) clearInterval(pollInterval.value);
|
||
|
||
const recordingId = fileItem.recordingId;
|
||
if (!recordingId) {
|
||
console.error(
|
||
"Cannot poll status without recording ID for",
|
||
fileItem.file.name
|
||
);
|
||
fileItem.status = "failed";
|
||
fileItem.error = "Internal error: Missing recording ID for polling.";
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
nextTick(startProcessingQueue);
|
||
return;
|
||
}
|
||
|
||
processingMessage.value = "Waiting for transcription...";
|
||
processingProgress.value = 40;
|
||
|
||
pollInterval.value = setInterval(async () => {
|
||
// Check if we should stop polling
|
||
const shouldStopPolling =
|
||
!currentlyProcessingFile.value ||
|
||
currentlyProcessingFile.value.clientId !== fileItem.clientId ||
|
||
fileItem.status === "failed" ||
|
||
(fileItem.status === "completed" &&
|
||
(!fileItem.willAutoSummarize || fileItem.summaryCompleted));
|
||
|
||
if (shouldStopPolling) {
|
||
console.log(
|
||
`Polling stopped for ${fileItem.clientId} as it's no longer active or finished.`
|
||
);
|
||
clearInterval(pollInterval.value);
|
||
pollInterval.value = null;
|
||
if (
|
||
currentlyProcessingFile.value &&
|
||
currentlyProcessingFile.value.clientId === fileItem.clientId
|
||
) {
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
await nextTick();
|
||
startProcessingQueue();
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log(
|
||
`Polling status for recording ID: ${recordingId} (${fileItem.file.name})`
|
||
);
|
||
const response = await fetch(`/tool/speakr/status/${recordingId}`);
|
||
if (!response.ok)
|
||
throw new Error(
|
||
`Status check failed with status ${response.status}`
|
||
);
|
||
|
||
const data = await response.json();
|
||
const galleryIndex = recordings.value.findIndex(
|
||
(r) => r.id === recordingId
|
||
);
|
||
|
||
if (galleryIndex !== -1) {
|
||
recordings.value[galleryIndex] = data;
|
||
if (selectedRecording.value?.id === recordingId) {
|
||
selectedRecording.value = data;
|
||
}
|
||
}
|
||
|
||
const previousStatus = fileItem.status;
|
||
fileItem.status = data.status;
|
||
fileItem.file.name = data.title || data.original_filename;
|
||
|
||
if (data.status === "COMPLETED") {
|
||
console.log(
|
||
`Processing COMPLETED for ${fileItem.file.name} (ID: ${recordingId})`
|
||
);
|
||
|
||
// If this was previously summarizing, it's now fully complete
|
||
if (previousStatus === "summarizing") {
|
||
console.log(`Auto-summary completed for ${fileItem.file.name}`);
|
||
processingMessage.value = "Processing complete!";
|
||
processingProgress.value = 100;
|
||
fileItem.status = "completed";
|
||
fileItem.summaryCompleted = true;
|
||
|
||
// This is final completion - clean up immediately and synchronously
|
||
clearInterval(pollInterval.value);
|
||
pollInterval.value = null;
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
|
||
// Keep completed items visible in the modal - don't remove them
|
||
console.log(
|
||
`Completed item ${fileItem.clientId} will remain visible in queue`
|
||
);
|
||
|
||
// Use immediate startProcessingQueue instead of nextTick to avoid duplication
|
||
startProcessingQueue();
|
||
return; // Exit early to prevent further processing
|
||
}
|
||
// If auto-summarization will occur and hasn't started yet, wait for it
|
||
else if (
|
||
fileItem.willAutoSummarize &&
|
||
!fileItem.hasCheckedForAutoSummary
|
||
) {
|
||
processingMessage.value = "Transcription complete!";
|
||
processingProgress.value = 85;
|
||
fileItem.status = "awaiting_summary"; // Use intermediate status to keep it in upload queue
|
||
// Don't mark as summaryCompleted yet, continue polling
|
||
}
|
||
// No auto-summarization expected, complete normally
|
||
else {
|
||
processingMessage.value = "Processing complete!";
|
||
processingProgress.value = 100;
|
||
fileItem.status = "completed";
|
||
fileItem.summaryCompleted = true; // No summary expected, so consider it complete
|
||
|
||
// Complete immediately for files without auto-summarization
|
||
clearInterval(pollInterval.value);
|
||
pollInterval.value = null;
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
|
||
// Keep completed items visible in the modal - don't remove them
|
||
console.log(
|
||
`Completed item ${fileItem.clientId} will remain visible in queue`
|
||
);
|
||
|
||
startProcessingQueue();
|
||
return; // Exit early to prevent further processing
|
||
}
|
||
|
||
// For files with auto-summarization, mark that they've been checked and continue polling
|
||
if (
|
||
fileItem.willAutoSummarize &&
|
||
!fileItem.hasCheckedForAutoSummary
|
||
) {
|
||
fileItem.hasCheckedForAutoSummary = true;
|
||
fileItem.autoSummaryStartTime = Date.now();
|
||
console.log(
|
||
`Auto-summary expected for ${fileItem.file.name}, continuing to poll...`
|
||
);
|
||
// Don't complete yet, continue polling
|
||
return;
|
||
}
|
||
|
||
// If we have auto-summarization and we've been waiting, check if we should timeout
|
||
if (
|
||
fileItem.willAutoSummarize &&
|
||
fileItem.hasCheckedForAutoSummary
|
||
) {
|
||
const waitTime = Date.now() - fileItem.autoSummaryStartTime;
|
||
const maxWaitTime = 60000; // 60 seconds
|
||
|
||
if (waitTime > maxWaitTime) {
|
||
// Timeout - complete the process
|
||
console.log(
|
||
`Auto-summary timeout for ${fileItem.file.name}, completing...`
|
||
);
|
||
processingMessage.value = "Processing complete!";
|
||
processingProgress.value = 100;
|
||
fileItem.status = "completed";
|
||
fileItem.summaryCompleted = true; // Mark as complete due to timeout
|
||
clearInterval(pollInterval.value);
|
||
pollInterval.value = null;
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
|
||
// Keep completed items visible in the modal - don't remove them
|
||
console.log(
|
||
`Timed-out item ${fileItem.clientId} will remain visible in queue`
|
||
);
|
||
|
||
startProcessingQueue();
|
||
} else {
|
||
// Still waiting for auto-summary, continue polling
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Normal completion path (no auto-summary check needed)
|
||
clearInterval(pollInterval.value);
|
||
pollInterval.value = null;
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
|
||
// Remove this item from uploadQueue immediately to prevent duplication
|
||
const queueIndex = uploadQueue.value.findIndex(
|
||
(item) => item.clientId === fileItem.clientId
|
||
);
|
||
if (queueIndex !== -1) {
|
||
uploadQueue.value.splice(queueIndex, 1);
|
||
console.log(
|
||
`Removed completed item ${fileItem.clientId} from queue immediately`
|
||
);
|
||
}
|
||
|
||
startProcessingQueue();
|
||
} else if (data.status === "FAILED") {
|
||
console.log(
|
||
`Processing FAILED for ${fileItem.file.name} (ID: ${recordingId})`
|
||
);
|
||
processingMessage.value = "Processing failed.";
|
||
processingProgress.value = 100;
|
||
fileItem.status = "failed";
|
||
fileItem.error =
|
||
data.transcription ||
|
||
data.summary ||
|
||
"Processing failed on server.";
|
||
setGlobalError(
|
||
`Processing failed for "${data.title || fileItem.file.name}".`
|
||
);
|
||
clearInterval(pollInterval.value);
|
||
pollInterval.value = null;
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
await nextTick();
|
||
startProcessingQueue();
|
||
} else if (data.status === "PROCESSING") {
|
||
// Check if this file will actually use chunking based on all conditions:
|
||
// 1. Chunking must be enabled in config
|
||
// 2. Must NOT be using ASR endpoint (ASR handles large files natively)
|
||
// 3. For size-based: File size must exceed the limit (can determine immediately)
|
||
// 4. For time-based: Can't determine client-side, but backend logs show it gets duration
|
||
|
||
const couldUseChunking =
|
||
chunkingEnabled.value && !useAsrEndpoint.value;
|
||
|
||
if (couldUseChunking) {
|
||
if (chunkingMode.value === "size") {
|
||
// Size-based chunking: we can determine definitively
|
||
const chunkThresholdBytes = chunkingLimit.value * 1024 * 1024;
|
||
const willUseChunking =
|
||
fileItem.file.size > chunkThresholdBytes;
|
||
|
||
if (willUseChunking) {
|
||
processingMessage.value =
|
||
"Processing large file (chunking in progress)...";
|
||
// If auto-summarization will occur, cap at 70%, otherwise 80%
|
||
const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
|
||
processingProgress.value = Math.round(
|
||
Math.min(
|
||
maxProgress,
|
||
processingProgress.value + Math.random() * 3
|
||
)
|
||
);
|
||
} else {
|
||
processingMessage.value = "转录进行中...";
|
||
// If auto-summarization will occur, cap at 65%, otherwise 75%
|
||
const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
|
||
processingProgress.value = Math.round(
|
||
Math.min(
|
||
maxProgress,
|
||
processingProgress.value + Math.random() * 5
|
||
)
|
||
);
|
||
}
|
||
} else {
|
||
// Duration-based chunking: Backend determines this after getting duration
|
||
// Show a neutral processing message since we can't know client-side
|
||
processingMessage.value =
|
||
"Processing file (chunking determined server-side)...";
|
||
const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
|
||
processingProgress.value = Math.round(
|
||
Math.min(
|
||
maxProgress,
|
||
processingProgress.value + Math.random() * 3
|
||
)
|
||
);
|
||
}
|
||
} else {
|
||
processingMessage.value = "转录进行中...";
|
||
const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
|
||
processingProgress.value = Math.round(
|
||
Math.min(
|
||
maxProgress,
|
||
processingProgress.value + Math.random() * 5
|
||
)
|
||
);
|
||
}
|
||
} else if (data.status === "SUMMARIZING") {
|
||
console.log(`Auto-summary started for ${fileItem.file.name}`);
|
||
processingMessage.value = "Generating summary...";
|
||
processingProgress.value = 90;
|
||
fileItem.status = "summarizing";
|
||
} else {
|
||
processingMessage.value = "Waiting in queue...";
|
||
processingProgress.value = 45;
|
||
}
|
||
} catch (error) {
|
||
console.error(
|
||
`Polling Error for ${fileItem.file.name} (ID: ${recordingId}):`,
|
||
error
|
||
);
|
||
fileItem.status = "failed";
|
||
fileItem.error = `Error checking status: ${error.message}`;
|
||
setGlobalError(
|
||
`Error checking status for "${fileItem.file.name}": ${error.message}.`
|
||
);
|
||
const galleryIndex = recordings.value.findIndex(
|
||
(r) => r.id === recordingId
|
||
);
|
||
if (galleryIndex !== -1)
|
||
recordings.value[galleryIndex].status = "FAILED";
|
||
|
||
clearInterval(pollInterval.value);
|
||
pollInterval.value = null;
|
||
resetCurrentFileProcessingState();
|
||
isProcessingActive.value = false;
|
||
await nextTick();
|
||
startProcessingQueue();
|
||
}
|
||
}, 5000);
|
||
};
|
||
|
||
//--------------------文件上传与上传队列 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)
|
||
const loadMoreRecordings = async () => {
|
||
if (!hasNextPage.value || isLoadingMore.value) return;
|
||
await loadRecordings(currentPage.value + 1, true, searchQuery.value);
|
||
};
|
||
|
||
// Search with debouncing
|
||
const performSearch = async (query = "") => {
|
||
currentPage.value = 1;
|
||
await loadRecordings(1, false, query);
|
||
};
|
||
|
||
// Debounced search function
|
||
const debouncedSearch = (query) => {
|
||
if (searchDebounceTimer.value) {
|
||
clearTimeout(searchDebounceTimer.value);
|
||
}
|
||
searchDebounceTimer.value = setTimeout(() => {
|
||
performSearch(query);
|
||
}, 300); // 300ms debounce
|
||
};
|
||
|
||
const loadTags = async () => {
|
||
try {
|
||
const response = await fetch("/tool/speakr/api/tags");
|
||
if (response.ok) {
|
||
availableTags.value = await response.json();
|
||
} else {
|
||
console.warn("Failed to load tags:", response.status);
|
||
availableTags.value = [];
|
||
}
|
||
} catch (error) {
|
||
console.warn("Error loading tags:", error);
|
||
availableTags.value = [];
|
||
}
|
||
};
|
||
|
||
const addTagToSelection = (tagId) => {
|
||
if (!selectedTagIds.value.includes(tagId)) {
|
||
selectedTagIds.value.push(tagId);
|
||
applyTagDefaults();
|
||
}
|
||
};
|
||
|
||
const removeTagFromSelection = (tagId) => {
|
||
const index = selectedTagIds.value.indexOf(tagId);
|
||
if (index > -1) {
|
||
selectedTagIds.value.splice(index, 1);
|
||
applyTagDefaults();
|
||
}
|
||
};
|
||
|
||
const applyTagDefaults = () => {
|
||
// Apply defaults from the first selected tag (highest priority)
|
||
const firstTag = selectedTags.value[0];
|
||
if (firstTag && useAsrEndpoint.value) {
|
||
if (firstTag.default_language) {
|
||
uploadLanguage.value = firstTag.default_language;
|
||
}
|
||
if (firstTag.default_min_speakers) {
|
||
uploadMinSpeakers.value = firstTag.default_min_speakers;
|
||
}
|
||
if (firstTag.default_max_speakers) {
|
||
uploadMaxSpeakers.value = firstTag.default_max_speakers;
|
||
}
|
||
}
|
||
};
|
||
|
||
// Legacy function for backward compatibility
|
||
const onTagSelected = applyTagDefaults;
|
||
|
||
// Tag helper functions
|
||
const getRecordingTags = (recording) => {
|
||
if (!recording || !recording.tags) return [];
|
||
return recording.tags || [];
|
||
};
|
||
|
||
const getAvailableTagsForRecording = (recording) => {
|
||
if (!recording || !availableTags.value) return [];
|
||
const recordingTagIds = getRecordingTags(recording).map(
|
||
(tag) => tag.id
|
||
);
|
||
return availableTags.value.filter(
|
||
(tag) => !recordingTagIds.includes(tag.id)
|
||
);
|
||
};
|
||
|
||
// Computed property for filtered available tags in the modal
|
||
const filteredAvailableTagsForModal = computed(() => {
|
||
if (!editingRecording.value) return [];
|
||
const availableTags = getAvailableTagsForRecording(
|
||
editingRecording.value
|
||
);
|
||
if (!tagSearchFilter.value) return availableTags;
|
||
|
||
const filter = tagSearchFilter.value.toLowerCase();
|
||
return availableTags.filter((tag) =>
|
||
tag.name.toLowerCase().includes(filter)
|
||
);
|
||
});
|
||
const filterByTag = (tag) => {
|
||
// Use advanced filter instead of text-based
|
||
filterTags.value = [tag.id];
|
||
applyAdvancedFilters();
|
||
};
|
||
const clearTagFilter = () => {
|
||
searchQuery.value = "";
|
||
clearAllFilters();
|
||
};
|
||
|
||
// Build search query from advanced filters
|
||
const buildSearchQuery = () => {
|
||
let query = [];
|
||
|
||
// Add text search
|
||
if (filterTextQuery.value.trim()) {
|
||
query.push(filterTextQuery.value.trim());
|
||
}
|
||
|
||
// Add tag filters
|
||
if (filterTags.value.length > 0) {
|
||
const tagNames = filterTags.value
|
||
.map((tagId) => {
|
||
const tag = availableTags.value.find((t) => t.id === tagId);
|
||
return tag ? `tag:${tag.name.replace(/\s+/g, "_")}` : "";
|
||
})
|
||
.filter(Boolean);
|
||
query.push(...tagNames);
|
||
}
|
||
|
||
// Add date filter
|
||
if (filterDatePreset.value) {
|
||
query.push(`date:${filterDatePreset.value}`);
|
||
} else if (filterDateRange.value.start || filterDateRange.value.end) {
|
||
// Custom date range - send as separate parameters
|
||
// Will be handled differently in the backend
|
||
if (filterDateRange.value.start) {
|
||
query.push(`date_from:${filterDateRange.value.start}`);
|
||
}
|
||
if (filterDateRange.value.end) {
|
||
query.push(`date_to:${filterDateRange.value.end}`);
|
||
}
|
||
}
|
||
|
||
return query.join(" ");
|
||
};
|
||
|
||
const applyAdvancedFilters = () => {
|
||
searchQuery.value = buildSearchQuery();
|
||
};
|
||
|
||
const clearAllFilters = () => {
|
||
filterTags.value = [];
|
||
filterDateRange.value = { start: "", end: "" };
|
||
filterDatePreset.value = "";
|
||
filterTextQuery.value = "";
|
||
searchQuery.value = "";
|
||
};
|
||
|
||
const editRecordingTags = (recording) => {
|
||
editingRecording.value = recording;
|
||
selectedNewTagId.value = "";
|
||
showEditTagsModal.value = true;
|
||
};
|
||
|
||
const closeEditTagsModal = () => {
|
||
showEditTagsModal.value = false;
|
||
editingRecording.value = null;
|
||
selectedNewTagId.value = "";
|
||
tagSearchFilter.value = ""; // Clear the filter when closing
|
||
};
|
||
|
||
const addTagToRecording = async (tagId = null) => {
|
||
// Use provided tagId or fall back to selectedNewTagId
|
||
const tagToAddId = tagId || selectedNewTagId.value;
|
||
if (!tagToAddId || !editingRecording.value) return;
|
||
|
||
try {
|
||
const csrfToken = document
|
||
.querySelector('meta[name="csrf-token"]')
|
||
?.getAttribute("content");
|
||
|
||
const response = await fetch(
|
||
`/tool/speakr/api/recordings/${editingRecording.value.id}/tags`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRFToken": csrfToken,
|
||
},
|
||
body: JSON.stringify({ tag_id: tagToAddId }),
|
||
}
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || "Failed to add tag");
|
||
}
|
||
|
||
// Update local recording data
|
||
const tagToAdd = availableTags.value.find(
|
||
(tag) => tag.id == tagToAddId
|
||
);
|
||
if (tagToAdd) {
|
||
if (!editingRecording.value.tags) {
|
||
editingRecording.value.tags = [];
|
||
}
|
||
editingRecording.value.tags.push(tagToAdd);
|
||
|
||
// Also update in recordings list if it's a different object
|
||
const recordingInList = recordings.value.find(
|
||
(r) => r.id === editingRecording.value.id
|
||
);
|
||
if (recordingInList && recordingInList !== editingRecording.value) {
|
||
if (!recordingInList.tags) {
|
||
recordingInList.tags = [];
|
||
}
|
||
recordingInList.tags.push(tagToAdd);
|
||
}
|
||
}
|
||
|
||
selectedNewTagId.value = "";
|
||
} catch (error) {
|
||
console.error("Error adding tag to recording:", error);
|
||
setGlobalError(`Failed to add tag: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
const removeTagFromRecording = async (tagId) => {
|
||
if (!editingRecording.value) return;
|
||
|
||
try {
|
||
const csrfToken = document
|
||
.querySelector('meta[name="csrf-token"]')
|
||
?.getAttribute("content");
|
||
|
||
const response = await fetch(
|
||
`/tool/speakr/api/recordings/${editingRecording.value.id}/tags/${tagId}`,
|
||
{
|
||
method: "DELETE",
|
||
headers: {
|
||
"X-CSRFToken": csrfToken,
|
||
},
|
||
}
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || "Failed to remove tag");
|
||
}
|
||
|
||
// Update local recording data
|
||
editingRecording.value.tags = editingRecording.value.tags.filter(
|
||
(tag) => tag.id !== tagId
|
||
);
|
||
|
||
// Also update in recordings list if it's a different object
|
||
const recordingInList = recordings.value.find(
|
||
(r) => r.id === editingRecording.value.id
|
||
);
|
||
if (
|
||
recordingInList &&
|
||
recordingInList !== editingRecording.value &&
|
||
recordingInList.tags
|
||
) {
|
||
recordingInList.tags = recordingInList.tags.filter(
|
||
(tag) => tag.id !== tagId
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error removing tag from recording:", error);
|
||
setGlobalError(`Failed to remove tag: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
//--------------------录音与标签数据加载 end------------------
|
||
|
||
//------------------- 录音控制与实时采集 start------------------
|
||
// --- Audio Recording ---
|
||
const startRecordingWithDisclaimer = async (mode = "microphone") => {
|
||
debugger;
|
||
// Check if disclaimer needs to be shown
|
||
if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) {
|
||
pendingRecordingMode.value = mode;
|
||
showRecordingDisclaimerModal.value = true;
|
||
} else {
|
||
// No disclaimer configured, proceed directly
|
||
await startRecordingActual(mode);
|
||
}
|
||
};
|
||
|
||
const acceptRecordingDisclaimer = async () => {
|
||
showRecordingDisclaimerModal.value = false;
|
||
if (pendingRecordingMode.value) {
|
||
await startRecordingActual(pendingRecordingMode.value);
|
||
pendingRecordingMode.value = null;
|
||
}
|
||
};
|
||
|
||
const cancelRecordingDisclaimer = () => {
|
||
showRecordingDisclaimerModal.value = false;
|
||
pendingRecordingMode.value = null;
|
||
};
|
||
|
||
const startRecording = startRecordingWithDisclaimer; // Maintain backward compatibility
|
||
let systemStream = null;
|
||
let micStream = null;
|
||
|
||
const startRecordingActual = async (mode = "microphone") => {
|
||
recordingMode.value = mode;
|
||
|
||
try {
|
||
// Load tags if not already loaded
|
||
if (availableTags.value.length === 0) {
|
||
await loadTags();
|
||
}
|
||
|
||
// Reset state
|
||
audioChunks.value = [];
|
||
audioBlobURL.value = null;
|
||
recordingNotes.value = "";
|
||
activeStreams.value = [];
|
||
// Clear previous tag selection and ASR options for fresh recording
|
||
selectedTags.value = [];
|
||
asrLanguage.value = "";
|
||
asrMinSpeakers.value = "";
|
||
asrMaxSpeakers.value = "";
|
||
|
||
// Reset draft-related state for fresh recording (Bug fix: prevents new recordings from being merged into old drafts)
|
||
currentDraftId.value = null;
|
||
isPaused.value = false;
|
||
pausedDuration.value = 0;
|
||
pausedTranscription.value = '';
|
||
segmentList = [];
|
||
segmentRenderStartIndex = 0;
|
||
asrResult.value = '';
|
||
asrResultOnline.value = '';
|
||
asrResultOffline.value = '';
|
||
recordingTime.value = 0;
|
||
isStoppingRecording.value = false;
|
||
pendingCorrectionCount = 0;
|
||
isEditingTranscription.value = false;
|
||
startNewRealtimeEpoch({ clearTimestampBarrier: true });
|
||
|
||
let combinedStream = null;
|
||
|
||
// Get microphone stream if needed
|
||
if (mode === "microphone" || mode === "both") {
|
||
if (!canRecordAudio.value) {
|
||
throw new Error(
|
||
"Microphone recording is not supported by your browser or permission was denied."
|
||
);
|
||
}
|
||
micStream = await navigator.mediaDevices.getUserMedia({
|
||
audio: true,
|
||
});
|
||
activeStreams.value.push(micStream);
|
||
showToast("Microphone access granted", "fa-microphone");
|
||
}
|
||
|
||
// Get system audio stream if needed
|
||
if (mode === "system" || mode === "both") {
|
||
if (!canRecordSystemAudio.value) {
|
||
throw new Error(
|
||
"System audio recording is not supported by your browser."
|
||
);
|
||
}
|
||
try {
|
||
systemStream = await navigator.mediaDevices.getDisplayMedia({
|
||
audio: true,
|
||
video: true, // Request video to enable system audio sharing prompt
|
||
});
|
||
|
||
// Check if the user actually granted audio permission
|
||
if (systemStream.getAudioTracks().length === 0) {
|
||
// Stop the video track if it exists, since we didn't get audio
|
||
systemStream.getVideoTracks().forEach((track) => track.stop());
|
||
throw new Error(
|
||
'System audio permission was not granted. Please ensure you check the "Share system audio" box.'
|
||
);
|
||
}
|
||
|
||
activeStreams.value.push(systemStream);
|
||
showToast("System audio access granted", "fa-desktop");
|
||
} catch (err) {
|
||
if (mode === "system") {
|
||
throw err; // Re-throw the original error to be caught by the outer handler
|
||
} else {
|
||
// For 'both' mode, fall back to microphone only
|
||
showToast(
|
||
"System audio denied, using microphone only",
|
||
"fa-exclamation-triangle"
|
||
);
|
||
mode = "microphone";
|
||
systemStream = null; // Make sure systemStream is null so it's not used later
|
||
}
|
||
}
|
||
}
|
||
|
||
// Combine streams if we have both
|
||
if (micStream && systemStream) {
|
||
try {
|
||
audioContext.value = new (window.AudioContext ||
|
||
window.webkitAudioContext)();
|
||
|
||
const micSource =
|
||
audioContext.value.createMediaStreamSource(micStream);
|
||
const systemSource =
|
||
audioContext.value.createMediaStreamSource(systemStream);
|
||
const destination =
|
||
audioContext.value.createMediaStreamDestination();
|
||
|
||
micSource.connect(destination);
|
||
systemSource.connect(destination);
|
||
|
||
// Create a new MediaStream with only the audio track from the destination
|
||
const mixedAudioTrack = destination.stream.getAudioTracks()[0];
|
||
if (!mixedAudioTrack) {
|
||
throw new Error("Failed to create mixed audio track");
|
||
}
|
||
|
||
combinedStream = new MediaStream([mixedAudioTrack]);
|
||
|
||
// Verify the stream has audio tracks
|
||
if (combinedStream.getAudioTracks().length === 0) {
|
||
throw new Error("Combined stream has no audio tracks");
|
||
}
|
||
|
||
console.log(
|
||
"Successfully created combined audio stream with",
|
||
combinedStream.getAudioTracks().length,
|
||
"audio tracks"
|
||
);
|
||
showToast(
|
||
"Recording both microphone and system audio",
|
||
"fa-microphone"
|
||
);
|
||
} catch (error) {
|
||
console.error("Failed to combine audio streams:", error);
|
||
// Fallback to system audio only
|
||
if (audioContext.value) {
|
||
audioContext.value
|
||
.close()
|
||
.catch((e) =>
|
||
console.error("Error closing AudioContext:", e)
|
||
);
|
||
audioContext.value = null;
|
||
}
|
||
combinedStream = systemStream;
|
||
showToast(
|
||
"Failed to combine audio, using system audio only",
|
||
"fa-exclamation-triangle"
|
||
);
|
||
}
|
||
} else if (systemStream) {
|
||
// For system audio only, create a new stream with just the audio tracks
|
||
const audioTracks = systemStream.getAudioTracks();
|
||
if (audioTracks.length > 0) {
|
||
combinedStream = new MediaStream(audioTracks);
|
||
console.log(
|
||
"Created system audio stream with",
|
||
audioTracks.length,
|
||
"audio tracks"
|
||
);
|
||
showToast("Recording system audio only", "fa-desktop");
|
||
} else {
|
||
throw new Error("System stream has no audio tracks");
|
||
}
|
||
} else if (micStream) {
|
||
combinedStream = micStream;
|
||
showToast("Recording microphone only", "fa-microphone");
|
||
} else {
|
||
throw new Error("No audio streams available for recording.");
|
||
}
|
||
|
||
// Setup MediaRecorder with optimized settings for transcription
|
||
const getOptimizedRecorderOptions = () => {
|
||
// Define transcription-optimized options in order of preference
|
||
const optionsList = [
|
||
// Best option: Opus codec at 32kbps (excellent compression for speech)
|
||
{
|
||
mimeType: "audio/webm;codecs=opus",
|
||
audioBitsPerSecond: 32000,
|
||
description: "Optimized (32kbps Opus)",
|
||
},
|
||
// Good option: Opus at 64kbps (slightly higher quality)
|
||
{
|
||
mimeType: "audio/webm;codecs=opus",
|
||
audioBitsPerSecond: 64000,
|
||
description: "Good quality (64kbps Opus)",
|
||
},
|
||
// Fallback 1: WebM with reduced bitrate
|
||
{
|
||
mimeType: "audio/webm",
|
||
audioBitsPerSecond: 64000,
|
||
description: "Standard WebM (64kbps)",
|
||
},
|
||
// Fallback 2: MP4 with reduced bitrate
|
||
{
|
||
mimeType: "audio/mp4",
|
||
audioBitsPerSecond: 64000,
|
||
description: "Standard MP4 (64kbps)",
|
||
},
|
||
// Fallback 3: Just the codec without bitrate
|
||
{
|
||
mimeType: "audio/webm;codecs=opus",
|
||
description: "Opus codec (default bitrate)",
|
||
},
|
||
// Fallback 4: Just WebM without bitrate
|
||
{
|
||
mimeType: "audio/webm",
|
||
description: "WebM (default bitrate)",
|
||
},
|
||
];
|
||
|
||
// Test each option to find the first supported one
|
||
for (const options of optionsList) {
|
||
if (MediaRecorder.isTypeSupported(options.mimeType)) {
|
||
console.log(
|
||
`Testing audio recording option: ${options.description} - ${options.mimeType}`
|
||
);
|
||
return options;
|
||
}
|
||
}
|
||
|
||
// Final fallback: no options (browser default)
|
||
console.log("Using browser default audio recording settings");
|
||
return null;
|
||
};
|
||
|
||
// Try to create MediaRecorder with progressive fallbacks
|
||
let mediaRecorderCreated = false;
|
||
let recorderOptions = getOptimizedRecorderOptions();
|
||
let attemptCount = 0;
|
||
|
||
while (!mediaRecorderCreated && attemptCount < 5) {
|
||
try {
|
||
attemptCount++;
|
||
|
||
if (recorderOptions && attemptCount === 1) {
|
||
// First attempt: try with full options
|
||
console.log(
|
||
`Attempt ${attemptCount}: Trying ${recorderOptions.description}`
|
||
);
|
||
mediaRecorder.value = new MediaRecorder(
|
||
combinedStream,
|
||
recorderOptions
|
||
);
|
||
actualBitrate.value =
|
||
recorderOptions.audioBitsPerSecond || 64000;
|
||
showToast(
|
||
`Recording: ${recorderOptions.description}`,
|
||
"fa-compress-alt",
|
||
3000
|
||
);
|
||
} else if (
|
||
recorderOptions &&
|
||
attemptCount === 2 &&
|
||
recorderOptions.audioBitsPerSecond
|
||
) {
|
||
// Second attempt: try same mime type without bitrate constraint
|
||
console.log(
|
||
`Attempt ${attemptCount}: Trying ${recorderOptions.mimeType} without bitrate`
|
||
);
|
||
mediaRecorder.value = new MediaRecorder(combinedStream, {
|
||
mimeType: recorderOptions.mimeType,
|
||
});
|
||
actualBitrate.value = 128000; // Estimate
|
||
showToast(
|
||
`Recording: ${recorderOptions.mimeType} (default bitrate)`,
|
||
"fa-compress-alt",
|
||
3000
|
||
);
|
||
} else {
|
||
// Final attempt: browser default
|
||
console.log(`Attempt ${attemptCount}: Using browser default`);
|
||
mediaRecorder.value = new MediaRecorder(combinedStream);
|
||
actualBitrate.value = 128000; // Estimate browser default
|
||
showToast(
|
||
"Recording with browser default settings",
|
||
"fa-microphone",
|
||
3000
|
||
);
|
||
}
|
||
|
||
mediaRecorderCreated = true;
|
||
console.log(
|
||
`MediaRecorder created successfully on attempt ${attemptCount}`
|
||
);
|
||
} catch (error) {
|
||
console.warn(
|
||
`MediaRecorder creation attempt ${attemptCount} failed:`,
|
||
error
|
||
);
|
||
mediaRecorder.value = null;
|
||
|
||
if (attemptCount >= 5) {
|
||
throw new Error(
|
||
`Failed to create MediaRecorder after ${attemptCount} attempts. Last error: ${error.message}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!mediaRecorder.value) {
|
||
throw new Error(
|
||
"Failed to create MediaRecorder with any configuration"
|
||
);
|
||
}
|
||
|
||
console.log(
|
||
`Recording with estimated bitrate: ${actualBitrate.value} bps`
|
||
);
|
||
|
||
mediaRecorder.value.ondataavailable = (event) =>
|
||
audioChunks.value.push(event.data);
|
||
mediaRecorder.value.onstop = () => {
|
||
const audioBlob = new Blob(audioChunks.value, {
|
||
type: "audio/webm",
|
||
});
|
||
audioBlobURL.value = URL.createObjectURL(audioBlob);
|
||
|
||
// Stop size monitoring
|
||
stopSizeMonitoring();
|
||
|
||
// Stop all active streams
|
||
activeStreams.value.forEach((stream) => {
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
});
|
||
activeStreams.value = [];
|
||
|
||
if (audioContext.value) {
|
||
audioContext.value
|
||
.close()
|
||
.catch((e) => console.error("Error closing AudioContext:", e));
|
||
audioContext.value = null;
|
||
}
|
||
cancelAnimationFrame(animationFrameId.value);
|
||
clearInterval(recordingInterval.value);
|
||
};
|
||
|
||
// --- Visualizer Setup ---
|
||
if (!audioContext.value) {
|
||
audioContext.value = new (window.AudioContext ||
|
||
window.webkitAudioContext)();
|
||
}
|
||
|
||
if (mode === "both" && micStream && systemStream) {
|
||
// Dual visualizer setup
|
||
micAnalyser.value = audioContext.value.createAnalyser();
|
||
micAnalyser.value.fftSize = 256;
|
||
const micSource =
|
||
audioContext.value.createMediaStreamSource(micStream);
|
||
micSource.connect(micAnalyser.value);
|
||
|
||
systemAnalyser.value = audioContext.value.createAnalyser();
|
||
systemAnalyser.value.fftSize = 256;
|
||
const systemSource =
|
||
audioContext.value.createMediaStreamSource(systemStream);
|
||
systemSource.connect(systemAnalyser.value);
|
||
} else {
|
||
// Single visualizer setup
|
||
const visualizerStream = micStream || systemStream;
|
||
if (visualizerStream) {
|
||
analyser.value = audioContext.value.createAnalyser();
|
||
analyser.value.fftSize = 256;
|
||
const source =
|
||
audioContext.value.createMediaStreamSource(visualizerStream);
|
||
source.connect(analyser.value);
|
||
}
|
||
}
|
||
|
||
// Start recording and timer
|
||
mediaRecorder.value.start();
|
||
isRecording.value = true;
|
||
recordingTime.value = 0;
|
||
recordingInterval.value = setInterval(
|
||
() => recordingTime.value++,
|
||
1000
|
||
);
|
||
|
||
// Start size monitoring
|
||
startSizeMonitoring();
|
||
|
||
// Switch to recording view
|
||
currentView.value = "recording";
|
||
|
||
// Start visualizer(s)
|
||
drawVisualizers();
|
||
|
||
setGlobalError(null);
|
||
} catch (err) {
|
||
console.error("Error starting recording:", err);
|
||
setGlobalError(`Could not start recording: ${err.message}`);
|
||
isRecording.value = false;
|
||
|
||
// Clean up any streams that were created
|
||
activeStreams.value.forEach((stream) => {
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
});
|
||
activeStreams.value = [];
|
||
}
|
||
};
|
||
|
||
const stopRecording = async () => {
|
||
if (isStoppingRecording.value) {
|
||
return;
|
||
}
|
||
|
||
const hasRecordingSession =
|
||
isRecording.value ||
|
||
isPaused.value ||
|
||
audioChunks.value.length > 0 ||
|
||
!!currentDraftId.value ||
|
||
activeStreams.value.length > 0;
|
||
|
||
if (!hasRecordingSession) {
|
||
return;
|
||
}
|
||
|
||
isStoppingRecording.value = true;
|
||
try {
|
||
isRec = false;
|
||
|
||
if (webReader) {
|
||
webReader.wsStop();
|
||
}
|
||
if (webReader2) {
|
||
webReader2.wsStop();
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 等待 MediaRecorder 生成最终数据
|
||
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', asrResult.value + asrResultOffline.value + asrResultOnline.value || '');
|
||
|
||
await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
// 清理 audioChunks 防止重复
|
||
audioChunks.value = [];
|
||
}
|
||
|
||
// 合并音频片段并获取预览 URL (不 finalize,只是下载合并后的音频用于预览)
|
||
const audioResp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/download`);
|
||
if (audioResp.ok) {
|
||
const audioBlob = await audioResp.blob();
|
||
audioBlobURL.value = URL.createObjectURL(audioBlob);
|
||
}
|
||
|
||
// 保持 currentDraftId 状态,以便用户选择上传或放弃
|
||
isPaused.value = false;
|
||
|
||
} catch (err) {
|
||
console.error('Error saving final segment:', err);
|
||
setGlobalError(`保存片段失败: ${err.message}`);
|
||
}
|
||
} else {
|
||
// 非草稿模式(直接录音),创建 audioBlobURL
|
||
if (audioChunks.value.length > 0) {
|
||
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
|
||
audioBlobURL.value = URL.createObjectURL(audioBlob);
|
||
}
|
||
}
|
||
|
||
if (isMobileScreen.value) {
|
||
isSidebarCollapsed.value = true;
|
||
}
|
||
} finally {
|
||
isStoppingRecording.value = false;
|
||
}
|
||
};
|
||
|
||
// 暂停录音
|
||
const pauseRecording = async () => {
|
||
if (!isRecording.value || isPaused.value) return;
|
||
|
||
try {
|
||
// 首先停止音频数据发送,防止向已关闭的 WebSocket 发送数据
|
||
isRec = false;
|
||
|
||
// 停止 WebSocket 转录
|
||
if (webReader) webReader.wsStop();
|
||
if (webReader2) webReader2.wsStop();
|
||
|
||
// 停止当前 MediaRecorder
|
||
if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
|
||
mediaRecorder.value.stop();
|
||
}
|
||
|
||
// 等待 MediaRecorder 生成最终数据
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
|
||
// 如果处于编辑模式,先退出编辑模式
|
||
if (isEditingTranscription.value) {
|
||
exitEditMode();
|
||
}
|
||
|
||
// 等待所有进行中的LLM矫正完成(最多8秒超时)
|
||
if (pendingCorrectionCount > 0) {
|
||
console.log(`等待 ${pendingCorrectionCount} 个LLM矫正完成...`);
|
||
const waitStart = Date.now();
|
||
while (pendingCorrectionCount > 0 && Date.now() - waitStart < 8000) {
|
||
await new Promise(resolve => setTimeout(resolve, 200));
|
||
}
|
||
if (pendingCorrectionCount > 0) {
|
||
console.warn(`超时:仍有 ${pendingCorrectionCount} 个LLM矫正未完成`);
|
||
}
|
||
}
|
||
|
||
// 创建草稿(如果是第一次暂停)
|
||
if (!currentDraftId.value) {
|
||
const resp = await fetch('/tool/speakr/api/draft/create', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
recording_mode: recordingMode.value,
|
||
notes: recordingNotes.value
|
||
})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
currentDraftId.value = data.draft.id;
|
||
} else {
|
||
throw new Error(data.error || '创建草稿失败');
|
||
}
|
||
}
|
||
|
||
// 上传当前片段
|
||
if (audioChunks.value.length > 0) {
|
||
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
|
||
const formData = new FormData();
|
||
formData.append('audio', audioBlob);
|
||
formData.append('duration', recordingTime.value - pausedDuration.value);
|
||
// 发送完整转录文本(历史 + 离线修正 + 当前在线结果)以防止覆盖历史记录
|
||
const fullTranscription = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
|
||
formData.append('transcription', fullTranscription);
|
||
|
||
await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
// Clear chunks after upload to prevent duplication
|
||
audioChunks.value = [];
|
||
}
|
||
|
||
// 更新状态
|
||
isPaused.value = true;
|
||
pausedDuration.value = recordingTime.value;
|
||
pausedTranscription.value = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
|
||
isRecording.value = false;
|
||
|
||
// 停止计时器和动画
|
||
clearInterval(recordingInterval.value);
|
||
cancelAnimationFrame(animationFrameId.value);
|
||
stopSizeMonitoring();
|
||
|
||
// 停止所有媒体流
|
||
activeStreams.value.forEach(stream => {
|
||
stream.getTracks().forEach(track => track.stop());
|
||
});
|
||
activeStreams.value = [];
|
||
micStream = null;
|
||
systemStream = null;
|
||
|
||
showToast('录音已暂停', 'fa-pause');
|
||
|
||
// 刷新草稿列表
|
||
loadDrafts();
|
||
} catch (err) {
|
||
console.error('Error pausing recording:', err);
|
||
setGlobalError(`暂停录音失败: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
// 恢复录音
|
||
const resumeRecording = async () => {
|
||
if (!isPaused.value || !currentDraftId.value) return;
|
||
|
||
try {
|
||
// 重置音频块
|
||
audioChunks.value = [];
|
||
|
||
// Bug fix: 清空segmentList,防止旧segment与pausedTranscription(历史文本)重复渲染
|
||
// pausedTranscription已经包含了历史转录,segmentList应该只包含恢复后的新转录
|
||
segmentList = [];
|
||
segmentRenderStartIndex = 0;
|
||
pendingCorrectionCount = 0;
|
||
isEditingTranscription.value = false;
|
||
startNewRealtimeEpoch({ clearTimestampBarrier: true });
|
||
|
||
let combinedStream = null;
|
||
const mode = recordingMode.value;
|
||
|
||
// 重新获取媒体流
|
||
if (mode === 'microphone' || mode === 'both') {
|
||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
activeStreams.value.push(micStream);
|
||
}
|
||
|
||
if (mode === 'system' || mode === 'both') {
|
||
try {
|
||
systemStream = await navigator.mediaDevices.getDisplayMedia({
|
||
audio: true,
|
||
video: true
|
||
});
|
||
if (systemStream.getAudioTracks().length === 0) {
|
||
systemStream.getVideoTracks().forEach(track => track.stop());
|
||
throw new Error('未获取系统音频权限');
|
||
}
|
||
activeStreams.value.push(systemStream);
|
||
} catch (err) {
|
||
if (mode === 'system') throw err;
|
||
showToast('系统音频权限被拒绝,仅使用麦克风', 'fa-exclamation-triangle');
|
||
recordingMode.value = 'microphone';
|
||
}
|
||
}
|
||
|
||
// 组合音频流
|
||
if (micStream && systemStream) {
|
||
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
|
||
const micSource = audioContext.value.createMediaStreamSource(micStream);
|
||
const systemSource = audioContext.value.createMediaStreamSource(systemStream);
|
||
const destination = audioContext.value.createMediaStreamDestination();
|
||
micSource.connect(destination);
|
||
systemSource.connect(destination);
|
||
combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]);
|
||
} else if (systemStream) {
|
||
combinedStream = new MediaStream(systemStream.getAudioTracks());
|
||
} else if (micStream) {
|
||
combinedStream = micStream;
|
||
} else {
|
||
throw new Error('无可用音频流');
|
||
}
|
||
|
||
// 创建新的 MediaRecorder
|
||
mediaRecorder.value = new MediaRecorder(combinedStream, { mimeType: 'audio/webm;codecs=opus' });
|
||
mediaRecorder.value.ondataavailable = (event) => audioChunks.value.push(event.data);
|
||
mediaRecorder.value.onstop = () => {
|
||
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
|
||
audioBlobURL.value = URL.createObjectURL(audioBlob);
|
||
};
|
||
|
||
// 重新设置可视化
|
||
if (!audioContext.value) {
|
||
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
|
||
if (mode === 'both' && micStream && systemStream) {
|
||
// Dual visualizer setup
|
||
micAnalyser.value = audioContext.value.createAnalyser();
|
||
micAnalyser.value.fftSize = 256;
|
||
const micSource = audioContext.value.createMediaStreamSource(micStream);
|
||
micSource.connect(micAnalyser.value);
|
||
|
||
systemAnalyser.value = audioContext.value.createAnalyser();
|
||
systemAnalyser.value.fftSize = 256;
|
||
const systemSource = audioContext.value.createMediaStreamSource(systemStream);
|
||
systemSource.connect(systemAnalyser.value);
|
||
} else {
|
||
const visualizerStream = micStream || systemStream;
|
||
if (visualizerStream) {
|
||
analyser.value = audioContext.value.createAnalyser();
|
||
analyser.value.fftSize = 256;
|
||
const source = audioContext.value.createMediaStreamSource(visualizerStream);
|
||
source.connect(analyser.value);
|
||
}
|
||
}
|
||
|
||
// 开始录音
|
||
mediaRecorder.value.start();
|
||
isPaused.value = false;
|
||
isRecording.value = true;
|
||
|
||
// 恢复计时器(从暂停时间继续)
|
||
recordingInterval.value = setInterval(() => recordingTime.value++, 1000);
|
||
|
||
// 恢复可视化
|
||
drawVisualizers();
|
||
startSizeMonitoring();
|
||
|
||
// 恢复 WebSocket 转录
|
||
console.log("Resuming WebSocket connections...");
|
||
// 设置重置标志,清空实时转录显示
|
||
resetTranscriptionFlag = true;
|
||
asrResultOnline.value = '';
|
||
if (webReader) {
|
||
const ret = webReader.wsStart();
|
||
if (ret === 1) {
|
||
isRec = true;
|
||
}
|
||
}
|
||
// offline/2pass
|
||
if (webReader2 && typeof wssBaseUrl !== 'undefined') {
|
||
try {
|
||
let urls = `wss://${wssBaseUrl.FUNASR_WEBSOCKET_IP}/websocket_offline`;
|
||
webReader2.wsStart(urls);
|
||
isRec = true;
|
||
} catch(e) {
|
||
console.warn("Failed to restart offline WS:", e);
|
||
}
|
||
}
|
||
|
||
showToast('录音已恢复', 'fa-play');
|
||
} catch (err) {
|
||
console.error('Error resuming recording:', err);
|
||
setGlobalError(`恢复录音失败: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
// 加载草稿列表
|
||
const loadDrafts = async () => {
|
||
try {
|
||
const resp = await fetch('/tool/speakr/api/draft/list');
|
||
const data = await resp.json();
|
||
draftRecordings.value = Array.isArray(data) ? data : [];
|
||
} catch (err) {
|
||
console.error('Error loading drafts:', err);
|
||
}
|
||
};
|
||
|
||
// 继续草稿录音
|
||
const continueDraftRecording = async (draftId) => {
|
||
try {
|
||
// 获取草稿详情
|
||
const resp = await fetch(`/tool/speakr/api/draft/${draftId}`);
|
||
const draft = await resp.json();
|
||
|
||
if (draft.error) {
|
||
throw new Error(draft.error);
|
||
}
|
||
|
||
// 恢复状态
|
||
currentDraftId.value = draftId;
|
||
recordingMode.value = draft.recording_mode || 'microphone';
|
||
recordingTime.value = Math.round(draft.total_duration || 0);
|
||
pausedDuration.value = recordingTime.value;
|
||
recordingNotes.value = draft.notes || '';
|
||
// Load historical transcription into asrResult
|
||
console.log('Loading draft transcription:', draft.combined_transcription ? draft.combined_transcription.length : 0, 'chars');
|
||
asrResult.value = draft.combined_transcription || '';
|
||
asrResultOnline.value = '';
|
||
asrResultOffline.value = '';
|
||
pausedTranscription.value = draft.combined_transcription || '';
|
||
segmentRenderStartIndex = 0;
|
||
|
||
// 设置暂停状态
|
||
isPaused.value = true;
|
||
isRecording.value = false;
|
||
|
||
// 切换到录音视图
|
||
currentView.value = 'recording';
|
||
|
||
showToast('草稿已加载,点击恢复继续录音', 'fa-folder-open');
|
||
} catch (err) {
|
||
console.error('Error continuing draft:', err);
|
||
setGlobalError(`加载草稿失败: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
// 删除草稿
|
||
const deleteDraft = async (draftId) => {
|
||
if (!confirm('确定要删除这个草稿吗?此操作不可撤销。')) return false;
|
||
|
||
try {
|
||
const resp = await fetch(`/tool/speakr/api/draft/${draftId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
const data = await resp.json();
|
||
|
||
if (data.success) {
|
||
showToast('草稿已删除', 'fa-trash');
|
||
loadDrafts();
|
||
|
||
// 如果删除的是当前草稿,重置状态
|
||
if (currentDraftId.value === draftId) {
|
||
currentDraftId.value = null;
|
||
isPaused.value = false;
|
||
pausedDuration.value = 0;
|
||
pausedTranscription.value = '';
|
||
segmentRenderStartIndex = 0;
|
||
}
|
||
return true;
|
||
} else {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error deleting draft:', err);
|
||
setGlobalError(`删除草稿失败: ${err.message}`);
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 重命名草稿
|
||
const startDraftRename = (draft) => {
|
||
editingDraftId.value = draft.id;
|
||
editingDraftName.value = draft.name;
|
||
// 等待 DOM 更新后聚焦输入框
|
||
nextTick(() => {
|
||
if (draftNameInput.value && draftNameInput.value[0]) {
|
||
draftNameInput.value[0].focus();
|
||
} else if (draftNameInput.value) {
|
||
draftNameInput.value.focus();
|
||
}
|
||
});
|
||
};
|
||
|
||
const saveDraftRename = async (draft) => {
|
||
if (!editingDraftId.value) return;
|
||
|
||
const newName = editingDraftName.value.trim();
|
||
editingDraftId.value = null; // 退出编辑模式
|
||
|
||
if (!newName || newName === draft.name) return;
|
||
|
||
try {
|
||
const resp = await fetch(`/tool/speakr/api/draft/${draft.id}/rename`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: newName })
|
||
});
|
||
const data = await resp.json();
|
||
|
||
if (data.success) {
|
||
draft.name = newName;
|
||
showToast('重命名成功', 'fa-check');
|
||
} else {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error renaming draft:', err);
|
||
showToast(`重命名失败: ${err.message}`, 'fa-exclamation-circle');
|
||
}
|
||
};
|
||
|
||
const uploadRecordedAudio = async () => {
|
||
// 如果有草稿,先获取合并后的音频文件,然后走统一上传流程
|
||
if (currentDraftId.value) {
|
||
try {
|
||
showToast('正在合并录音片段...', 'fa-spinner fa-spin', 0);
|
||
|
||
// 1. 调用 finalize 获取合并后的音频 Blob(不再创建Recording)
|
||
const resp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/finalize`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
// 尝试解析JSON错误消息
|
||
try {
|
||
const err = await resp.json();
|
||
throw new Error(err.error || '合并音频失败');
|
||
} catch {
|
||
throw new Error('合并音频失败');
|
||
}
|
||
}
|
||
|
||
const audioBlob = await resp.blob();
|
||
|
||
// 2. 创建 File 对象,走统一上传流程
|
||
const recordedFile = new File(
|
||
[audioBlob],
|
||
createLocalizedRecordingFilename(),
|
||
{ type: "audio/webm" }
|
||
);
|
||
|
||
// 3. 清理草稿状态
|
||
const savedNotes = recordingNotes.value;
|
||
const savedTags = [...selectedTags.value];
|
||
const savedAsrOptions = {
|
||
language: asrLanguage.value,
|
||
min_speakers: asrMinSpeakers.value,
|
||
max_speakers: asrMaxSpeakers.value,
|
||
};
|
||
|
||
currentDraftId.value = null;
|
||
pausedDuration.value = 0;
|
||
pausedTranscription.value = '';
|
||
segmentRenderStartIndex = 0;
|
||
loadDrafts();
|
||
|
||
// 4. 走统一上传流程(和直接录制一样使用 addFilesToQueue)
|
||
const previousQueueLength = uploadQueue.value.length;
|
||
|
||
addFilesToQueue([{
|
||
file: recordedFile,
|
||
notes: savedNotes,
|
||
tags: savedTags,
|
||
asrOptions: savedAsrOptions,
|
||
}]);
|
||
|
||
if (uploadQueue.value.length === previousQueueLength) {
|
||
return;
|
||
}
|
||
|
||
const queueItem = uploadQueue.value[uploadQueue.value.length - 1];
|
||
|
||
showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0);
|
||
|
||
// 5. 等待上传响应
|
||
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);
|
||
});
|
||
};
|
||
|
||
await waitForServerResponse();
|
||
|
||
// 6. 清理界面状态
|
||
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;
|
||
|
||
showToast("上传成功,正在处理中...", "fa-check-circle");
|
||
|
||
} catch (err) {
|
||
console.error('Error uploading draft:', err);
|
||
setGlobalError(`上传失败: ${err.message}`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!audioBlobURL.value) {
|
||
setGlobalError("No recorded audio to upload.");
|
||
return;
|
||
}
|
||
|
||
const previousQueueLength = uploadQueue.value.length;
|
||
|
||
const recordedFile = new File(
|
||
audioChunks.value,
|
||
createLocalizedRecordingFilename(),
|
||
{ type: "audio/webm" }
|
||
);
|
||
|
||
|
||
addFilesToQueue([
|
||
{
|
||
file: recordedFile,
|
||
notes: recordingNotes.value,
|
||
tags: selectedTags.value,
|
||
asrOptions: {
|
||
language: asrLanguage.value,
|
||
min_speakers: asrMinSpeakers.value,
|
||
max_speakers: asrMaxSpeakers.value,
|
||
},
|
||
},
|
||
]);
|
||
|
||
|
||
if (uploadQueue.value.length === previousQueueLength) {
|
||
return;
|
||
}
|
||
|
||
const queueItem = uploadQueue.value[uploadQueue.value.length - 1];
|
||
|
||
showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0);
|
||
|
||
const waitForServerResponse = () => {
|
||
return new Promise((resolve, reject) => {
|
||
const checkInterval = setInterval(() => {
|
||
|
||
if (!queueItem || queueItem.status === 'failed') {
|
||
clearInterval(checkInterval);
|
||
reject(new Error(queueItem?.error || "Upload failed"));
|
||
return;
|
||
}
|
||
|
||
const successStates = ['pending', 'processing', 'transcribing', 'summarizing', 'completed'];
|
||
if (successStates.includes(queueItem.status)) {
|
||
clearInterval(checkInterval);
|
||
resolve(true);
|
||
}
|
||
|
||
}, 200);
|
||
});
|
||
};
|
||
try {
|
||
|
||
await waitForServerResponse();
|
||
|
||
discardRecording();
|
||
|
||
if (isRec === true) {
|
||
if (typeof stopRecording === 'function') stopRecording();
|
||
}
|
||
|
||
currentView.value = "upload";
|
||
selectedRecording.value = null;
|
||
if (isMobileScreen.value) {
|
||
isSidebarCollapsed.value = true;
|
||
}
|
||
|
||
showToast("上传成功,正在处理中...", "fa-check-circle");
|
||
|
||
} catch (error) {
|
||
console.error("Server upload failed:", error);
|
||
showToast(`上传失败: ${error.message || '请检查网络'}`, "fa-exclamation-triangle", 4000);
|
||
}
|
||
};
|
||
const downloadRecordedAudio = () => {
|
||
if (!audioBlobURL.value) {
|
||
showToast("No recording available to download", "fa-exclamation-circle");
|
||
return;
|
||
}
|
||
|
||
const filename = createLocalizedRecordingFilename();
|
||
|
||
const a = document.createElement('a');
|
||
a.style.display = 'none';
|
||
a.href = audioBlobURL.value;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
|
||
// 清理
|
||
setTimeout(() => {
|
||
document.body.removeChild(a);
|
||
}, 100);
|
||
|
||
showToast("录音已开始下载", "fa-download");
|
||
};
|
||
const discardRecording = async () => {
|
||
if (window.tempFinalizedRecording) {
|
||
// 如果是因为停止草稿而生成的临时录音,放弃时需要从服务器删除
|
||
try {
|
||
await fetch(`/tool/speakr/api/draft/${window.tempFinalizedRecording.id}`, {
|
||
method: 'DELETE' // 注意:这里可能需要一个专门删除 Recording 的 API,因为 draft 已经转成 recording 了
|
||
// 但我们的后端 /api/draft/<id> DELETE 实际上是删除 DraftRecording
|
||
// 草稿 finalize 后,DraftRecording 已经被删除了,生成了 Recording
|
||
// 所以我们需要调用删除 Recording 的 API
|
||
});
|
||
// 暂时没有通用的删除 Recording API 暴露给这里?
|
||
// 我们可以复用 deleteRecording 逻辑,但这需要确认 API
|
||
// 让我们查看 deleteRecording 的实现
|
||
const resp = await fetch(`/tool/speakr/recording/${window.tempFinalizedRecording.id}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
} catch (e) {
|
||
console.error("Failed to delete temp recording", e);
|
||
}
|
||
window.tempFinalizedRecording = null;
|
||
}
|
||
|
||
if (currentDraftId.value) {
|
||
const deleted = await deleteDraft(currentDraftId.value);
|
||
if (!deleted) return;
|
||
}
|
||
|
||
if (audioBlobURL.value) {
|
||
URL.revokeObjectURL(audioBlobURL.value);
|
||
}
|
||
audioBlobURL.value = null;
|
||
audioChunks.value = [];
|
||
isRecording.value = false;
|
||
recordingTime.value = 0;
|
||
if (recordingInterval.value) clearInterval(recordingInterval.value);
|
||
recordingNotes.value = "";
|
||
// Clear tags and ASR options for fresh start
|
||
selectedTagIds.value = [];
|
||
asrLanguage.value = "";
|
||
asrMinSpeakers.value = "";
|
||
asrMaxSpeakers.value = "";
|
||
};
|
||
|
||
const drawSingleVisualizer = (analyserNode, canvasElement) => {
|
||
if (!analyserNode || !canvasElement) return;
|
||
|
||
const bufferLength = analyserNode.frequencyBinCount;
|
||
const dataArray = new Uint8Array(bufferLength);
|
||
analyserNode.getByteFrequencyData(dataArray);
|
||
|
||
const canvasCtx = canvasElement.getContext("2d");
|
||
const WIDTH = canvasElement.width;
|
||
const HEIGHT = canvasElement.height;
|
||
|
||
canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
|
||
|
||
const barWidth = (WIDTH / bufferLength) * 1.5;
|
||
let barHeight;
|
||
let x = 0;
|
||
|
||
// Use theme-specific colors that work with all color schemes
|
||
const buttonColor = getComputedStyle(document.documentElement)
|
||
.getPropertyValue("--bg-button")
|
||
.trim();
|
||
const buttonHoverColor = getComputedStyle(document.documentElement)
|
||
.getPropertyValue("--bg-button-hover")
|
||
.trim();
|
||
|
||
// Create gradient that works in both light and dark modes
|
||
const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
|
||
if (isDarkMode.value) {
|
||
// Dark mode: use button colors with transparency
|
||
gradient.addColorStop(0, buttonColor);
|
||
gradient.addColorStop(0.6, buttonHoverColor);
|
||
gradient.addColorStop(1, "rgba(0, 0, 0, 0.2)");
|
||
} else {
|
||
// Light mode: use more saturated colors for visibility
|
||
gradient.addColorStop(0, buttonColor);
|
||
gradient.addColorStop(0.5, buttonHoverColor);
|
||
gradient.addColorStop(1, "rgba(0, 0, 0, 0.1)");
|
||
}
|
||
|
||
for (let i = 0; i < bufferLength; i++) {
|
||
barHeight = dataArray[i] / 2.5;
|
||
canvasCtx.fillStyle = gradient;
|
||
canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
|
||
x += barWidth + 2;
|
||
}
|
||
};
|
||
|
||
const drawVisualizers = () => {
|
||
if (!isRecording.value) {
|
||
if (animationFrameId.value) {
|
||
cancelAnimationFrame(animationFrameId.value);
|
||
animationFrameId.value = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
animationFrameId.value = requestAnimationFrame(drawVisualizers);
|
||
|
||
if (recordingMode.value === "both") {
|
||
drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
|
||
drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
|
||
} else {
|
||
drawSingleVisualizer(analyser.value, visualizer.value);
|
||
}
|
||
};
|
||
|
||
//--------------------录音控制与实时采集 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");
|
||
}
|
||
};
|
||
|
||
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 ---
|
||
const 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") {
|
||
// Initialize recording markdown editor when switching to recording view
|
||
nextTick(() => {
|
||
initializeRecordingMarkdownEditor();
|
||
getBaseInfo();
|
||
is_final.value = false;
|
||
// 清空上一次的实时转录文本
|
||
asrResultOnline.value = "";
|
||
// ===== 清理旧的 Recorder 实例 =====
|
||
// 根本原因修复:旧 rec 的 onProcess 回调在旧闭包中持续活跃,
|
||
// 向已停止的旧 wsconnecter 发送数据,导致缓冲区溢出和 WebSocket 未连接刷屏
|
||
if (rec) {
|
||
try { rec.close(); } catch(e) { console.warn('关闭旧rec失败:', e); }
|
||
rec = null;
|
||
}
|
||
if (rec2) {
|
||
try { rec2.close(); } catch(e) { console.warn('关闭旧rec2失败:', e); }
|
||
rec2 = null;
|
||
}
|
||
if (asrAudioContext) {
|
||
try { asrAudioContext.close(); } catch(e) {}
|
||
asrAudioContext = null;
|
||
}
|
||
if (asrAudioContext2) {
|
||
try { asrAudioContext2.close(); } catch(e) {}
|
||
asrAudioContext2 = null;
|
||
}
|
||
//语音实时转换相关
|
||
//online的ws示例
|
||
var wsconnecter = new WebSocketConnectMethod({
|
||
msgHandle: getJsonMessage,
|
||
stateHandle: getConnState,
|
||
});
|
||
//offline的ws示例
|
||
var wsconnecter2 = new WebSocketConnectMethod({
|
||
msgHandle: getJsonMessage2,
|
||
stateHandle: getConnState2,
|
||
});
|
||
webReader = wsconnecter;
|
||
webReader2 = wsconnecter2;
|
||
var audioBlob;
|
||
var sampleBuf = new Int16Array();
|
||
var sampleBuf2 = new Int16Array();
|
||
const asrChunkSize = 960;
|
||
const asrBufferWarnSamples = 20000;
|
||
const asrBufferHardLimitSamples = 16000 * 5;
|
||
var rec_text = "";
|
||
var offline_text = "";
|
||
var rec_textOnline = "";
|
||
var offlineSentenceHandoffText = "";
|
||
var rec_textoffline = "";
|
||
//------------------- 实时 ASR 待矫正句子队列 start------------------
|
||
// pendingSentenceQueue 是 2pass-offline 文本进入正式转录前的等待队列。
|
||
// 每条 offline 句子先入队,再等待 /api/asr/correct 的 LLM 矫正结果。
|
||
// LLM 返回后由 finalizePendingSentence 标记 processed,再按队首顺序 flush 到 segmentList。
|
||
var pendingSentenceQueue = [];
|
||
let pendingSentenceSequence = 0;
|
||
var lastOnlineTimestamp = null; // 记录上次在线转录的时间戳
|
||
|
||
// extractTimestampMs: FunASR 返回的 timestamp 通常是二维数组或 JSON 字符串。
|
||
// 这里取最后一段的结束时间,用它判断消息新旧、辅助说话人回填和去重。
|
||
function extractTimestampMs(timestampPayload) {
|
||
if (!timestampPayload) {
|
||
// 没有 timestamp 时无法做时间屏障判断,后续逻辑会把它当作无时间消息处理。
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const timestampData =
|
||
typeof timestampPayload === "string"
|
||
? JSON.parse(timestampPayload)
|
||
: timestampPayload;
|
||
|
||
if (!Array.isArray(timestampData) || timestampData.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// FunASR timestamp 的最后一项代表当前句子末尾附近的时间。
|
||
const lastItem = timestampData[timestampData.length - 1];
|
||
if (!Array.isArray(lastItem) || lastItem.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// 优先取结束时间;部分返回只有 start 时退回取第一个值。
|
||
const endOrStart =
|
||
lastItem.length > 1 ? Number(lastItem[1]) : Number(lastItem[0]);
|
||
return Number.isFinite(endOrStart) ? endOrStart : null;
|
||
} catch (error) {
|
||
// timestamp 解析失败时不阻断转写,只放弃基于时间戳的旧消息判断。
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// getLatestRealtimeTimestamp: 手动编辑实时转写时需要建立时间屏障。
|
||
// 这里从正式 segment、pending 队列和 online 临时文本中取最新时间,避免旧消息回灌。
|
||
function getLatestRealtimeTimestamp() {
|
||
const latestSegmentTimestamp = segmentList.reduce(
|
||
(latest, segment) =>
|
||
Number.isFinite(segment?.timestampMs)
|
||
? Math.max(latest, segment.timestampMs)
|
||
: latest,
|
||
-1
|
||
);
|
||
const latestPendingTimestamp = pendingSentenceQueue.reduce(
|
||
(latest, item) =>
|
||
Number.isFinite(item.timestampMs)
|
||
? Math.max(latest, item.timestampMs)
|
||
: latest,
|
||
-1
|
||
);
|
||
const latestOnlineTimestamp = Number.isFinite(lastOnlineTimestamp)
|
||
? lastOnlineTimestamp
|
||
: -1;
|
||
|
||
return Math.max(
|
||
latestSegmentTimestamp,
|
||
latestPendingTimestamp,
|
||
latestOnlineTimestamp
|
||
);
|
||
}
|
||
|
||
// 只保留尾部文本作为 overlap 参照,避免手动编辑后 offline 结果把已编辑内容重复带回来。
|
||
function getRealtimeCarryoverTail(text, maxLength = 160) {
|
||
return (text || "").slice(-maxLength);
|
||
}
|
||
|
||
// getSharedRealtimeSuffix: 找原始实时文本和用户编辑后文本之间仍然相同的尾部。
|
||
// 该函数用于辅助构造 realtimeEditCarryover,减少后续 offline 文本重复拼接。
|
||
function getSharedRealtimeSuffix(
|
||
originalText,
|
||
editedText,
|
||
maxLength = 80
|
||
) {
|
||
const original = originalText || "";
|
||
const edited = editedText || "";
|
||
const maxSuffixLength = Math.min(
|
||
maxLength,
|
||
original.length,
|
||
edited.length
|
||
);
|
||
|
||
for (let size = maxSuffixLength; size >= 1; size--) {
|
||
const suffix = edited.slice(-size);
|
||
if (original.endsWith(suffix)) {
|
||
return suffix;
|
||
}
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
// buildRealtimeEditCarryover: 用户手动编辑实时转写后,记录一小段尾巴。
|
||
// 下一条 2pass-offline 到达时,会用这段尾巴裁掉重复内容。
|
||
function buildRealtimeEditCarryover(baseText) {
|
||
const tailText = getRealtimeCarryoverTail(baseText);
|
||
if (tailText.length < 4) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
tailText,
|
||
};
|
||
}
|
||
|
||
// trimOfflineTextWithCarryover: 裁掉 offline 文本中与手动编辑尾巴重叠的部分。
|
||
// 例:用户已编辑到 "今天会议讨论A",offline 又返回 "会议讨论A和B",则只保留 "和B"。
|
||
function trimOfflineTextWithCarryover(rawText) {
|
||
const incomingText = rawText || "";
|
||
if (!realtimeEditCarryover || !incomingText) {
|
||
// 没有手动编辑 carryover,或当前 offline 文本为空,直接返回原文。
|
||
return incomingText;
|
||
}
|
||
|
||
const tailText = realtimeEditCarryover.tailText || "";
|
||
const minOverlapLength = 4;
|
||
const maxOverlapLength = Math.min(
|
||
80,
|
||
tailText.length,
|
||
incomingText.length
|
||
);
|
||
|
||
// 从最长 overlap 开始找,优先裁掉最大重复片段,减少重复显示。
|
||
for (
|
||
let size = maxOverlapLength;
|
||
size >= minOverlapLength;
|
||
size--
|
||
) {
|
||
const overlapText = tailText.slice(-size);
|
||
const matchIndex = incomingText.lastIndexOf(overlapText);
|
||
if (matchIndex === -1) {
|
||
continue;
|
||
}
|
||
|
||
// 找到重叠片段后,只把重叠之后的新内容作为本次 pending 文本。
|
||
const remainingText = incomingText.slice(
|
||
matchIndex + overlapText.length
|
||
);
|
||
// 更新 carryover 尾巴,让后续 offline 文本继续用最新上下文去重。
|
||
realtimeEditCarryover.tailText = getRealtimeCarryoverTail(
|
||
tailText + remainingText
|
||
);
|
||
return remainingText;
|
||
}
|
||
|
||
// 没有找到 overlap,说明后续 offline 文本已经和编辑尾巴接不上,停止 carryover 逻辑。
|
||
realtimeEditCarryover = null;
|
||
return incomingText;
|
||
}
|
||
|
||
function clearRealtimePipeline({
|
||
preserveTimestampBarrier = false,
|
||
} = {}) {
|
||
sampleBuf = new Int16Array();
|
||
sampleBuf2 = new Int16Array();
|
||
rec_text = "";
|
||
offline_text = "";
|
||
rec_textOnline = "";
|
||
offlineSentenceHandoffText = "";
|
||
rec_textoffline = "";
|
||
pendingSentenceQueue = [];
|
||
pendingSentenceSequence = 0;
|
||
lastOnlineTimestamp = null;
|
||
asrResultOnline.value = "";
|
||
asrResultOffline.value = "";
|
||
|
||
if (!preserveTimestampBarrier) {
|
||
realtimeTimestampBarrierMs = -1;
|
||
}
|
||
|
||
syncRealtimePreview();
|
||
}
|
||
|
||
// isStaleRealtimeMessage: 判断一条 WebSocket 回包是否属于旧录音周期或旧时间段。
|
||
// 返回 true 时,ensurePendingSentence 会直接丢弃,避免暂停/恢复/手动编辑后的旧消息污染当前文本。
|
||
function isStaleRealtimeMessage(
|
||
messageTimestampMs,
|
||
messageEpoch = liveTranscriptionEpoch
|
||
) {
|
||
if (messageEpoch !== liveTranscriptionEpoch) {
|
||
// epoch 不一致表示消息来自上一轮实时转写,必须丢弃。
|
||
return true;
|
||
}
|
||
|
||
return (
|
||
// 时间戳小于等于屏障,说明这条消息早于用户编辑/重置点,不能再回灌。
|
||
Number.isFinite(messageTimestampMs) &&
|
||
realtimeTimestampBarrierMs >= 0 &&
|
||
messageTimestampMs <= realtimeTimestampBarrierMs
|
||
);
|
||
}
|
||
clearRealtimePipelineRef = clearRealtimePipeline;
|
||
getLatestRealtimeTimestampRef = getLatestRealtimeTimestamp;
|
||
buildRealtimeEditCarryoverRef = buildRealtimeEditCarryover;
|
||
renderFullTextRef = renderFullText;
|
||
|
||
// getPendingSentencePreviewText: pending 队列还未正式落到 segmentList,
|
||
// 但用户仍需要在实时预览区先看到这些句子。
|
||
function getPendingSentencePreviewText() {
|
||
return pendingSentenceQueue
|
||
.filter((item) => item.epoch === liveTranscriptionEpoch)
|
||
.map((item) => item.correctedText || item.rawText)
|
||
.join("");
|
||
}
|
||
|
||
// syncRealtimePreview: 刷新右侧/实时区域的临时展示文本。
|
||
// 展示顺序:待矫正 offline 句子 + online/offline 交接文本 + 当前 online 临时文本。
|
||
function syncRealtimePreview() {
|
||
asrResultOnline.value =
|
||
getPendingSentencePreviewText() +
|
||
offlineSentenceHandoffText +
|
||
rec_textOnline;
|
||
}
|
||
|
||
// findPendingSentenceBySource: 用 sourceText + timestamp + epoch 做幂等去重。
|
||
// 同一条 offline 句子如果重复返回,直接复用已有 pending/segment,避免重复矫正和重复落句。
|
||
function findPendingSentenceBySource(
|
||
sourceText,
|
||
pendingTimestamp,
|
||
messageEpoch = liveTranscriptionEpoch
|
||
) {
|
||
const normalizedSourceText = sourceText || "";
|
||
const normalizedTimestamp = pendingTimestamp || "";
|
||
return (
|
||
// 先查 pending 队列:句子可能正在等待 LLM 矫正。
|
||
pendingSentenceQueue.find(
|
||
(item) =>
|
||
item.epoch === messageEpoch &&
|
||
item.sourceText === normalizedSourceText &&
|
||
item.timestamp === normalizedTimestamp
|
||
) ||
|
||
// 再查正式 segmentList:句子可能已经矫正完成并落入正式转录。
|
||
segmentList.find(
|
||
(item) =>
|
||
item.epoch === messageEpoch &&
|
||
item.sourceText === normalizedSourceText &&
|
||
item.timestamp === normalizedTimestamp
|
||
) ||
|
||
null
|
||
);
|
||
}
|
||
|
||
// ensurePendingSentence: 2pass-offline 文本进入 LLM 矫正前的统一入口。
|
||
// 它负责:丢弃旧消息、去重、裁掉手动编辑造成的重复尾巴、创建 pending item、刷新预览。
|
||
// 注意:这里不会正式写入 segmentList,正式落句在 finalizePendingSentence/flushProcessedPendingSentences。
|
||
function ensurePendingSentence(
|
||
sourceText,
|
||
pendingTimestamp,
|
||
messageEpoch = liveTranscriptionEpoch
|
||
) {
|
||
// 先把 FunASR timestamp 归一化为毫秒数,用于旧消息过滤和后续 speaker 对齐。
|
||
const timestampMs = extractTimestampMs(pendingTimestamp);
|
||
if (isStaleRealtimeMessage(timestampMs, messageEpoch)) {
|
||
// 旧录音周期或旧时间屏障之前的消息直接丢弃,不进入 pending 队列。
|
||
return null;
|
||
}
|
||
|
||
// 同一 sourceText/timestamp/epoch 已经存在时直接复用,保证重复 WebSocket 回包幂等。
|
||
const existingItem = findPendingSentenceBySource(
|
||
sourceText,
|
||
pendingTimestamp,
|
||
messageEpoch
|
||
);
|
||
if (existingItem) {
|
||
// 返回已有 item,调用方会看到它已请求/已处理状态,从而避免重复请求 LLM。
|
||
return existingItem;
|
||
}
|
||
|
||
// 裁掉与手动编辑尾巴重叠的部分,得到真正需要进入矫正流程的新文本。
|
||
const trimmedText = trimOfflineTextWithCarryover(sourceText || "");
|
||
if (!trimmedText) {
|
||
// 裁剪后没有新内容,说明这条 offline 文本只是重复片段,不入队。
|
||
syncRealtimePreview();
|
||
return null;
|
||
}
|
||
|
||
// pendingItem 是 LLM 矫正流程的最小状态单元。
|
||
const pendingItem = {
|
||
id: pendingSentenceSequence++, // 本地递增 ID,LLM 返回时用它定位这条 pending 句子。
|
||
sourceText: sourceText || "", // FunASR 原始文本,用于去重和保留来源。
|
||
rawText: trimmedText, // 裁剪后的文本,后续会作为 /api/asr/correct 的输入。
|
||
timestamp: pendingTimestamp || "", // 原始 timestamp 字符串/对象,用于去重和渲染时断句。
|
||
timestampMs, // 毫秒时间点,用于旧消息过滤和 offline-speaker 对齐。
|
||
correctedText: "", // LLM/标准词映射后的文本,finalize 前保持为空。
|
||
processed: false, // false 表示还不能 flush 到正式 segmentList。
|
||
correctionRequested: false, // 防止同一句重复发起 LLM 矫正请求。
|
||
epoch: messageEpoch, // 当前实时转写周期,防止上一轮录音的异步结果混入。
|
||
};
|
||
// 新 pending 先进入等待队列,让实时预览能立即显示,同时等待 LLM 矫正结果。
|
||
pendingSentenceQueue.push(pendingItem);
|
||
// 刷新临时预览;正式主文本 asrResult 要等 processed 后由 flushProcessedPendingSentences 更新。
|
||
syncRealtimePreview();
|
||
return pendingItem;
|
||
}
|
||
//--------------------实时 ASR 待矫正句子队列 end------------------
|
||
function flushProcessedPendingSentences() {
|
||
let didFlush = false;
|
||
while (pendingSentenceQueue.length > 0) {
|
||
if (pendingSentenceQueue[0].epoch !== liveTranscriptionEpoch) {
|
||
pendingSentenceQueue.shift();
|
||
continue;
|
||
}
|
||
if (!pendingSentenceQueue[0].processed) {
|
||
break;
|
||
}
|
||
|
||
const item = pendingSentenceQueue.shift();
|
||
segmentList.push({
|
||
text: item.correctedText || item.rawText,
|
||
sourceText: item.sourceText,
|
||
timestamp: item.timestamp,
|
||
timestampMs: item.timestampMs,
|
||
speaker: null,
|
||
processed: true,
|
||
epoch: item.epoch,
|
||
});
|
||
didFlush = true;
|
||
}
|
||
|
||
if (didFlush) {
|
||
renderFullText();
|
||
} else {
|
||
syncRealtimePreview();
|
||
}
|
||
}
|
||
function finalizePendingSentence(
|
||
pendingSentenceId,
|
||
finalText,
|
||
messageEpoch = liveTranscriptionEpoch
|
||
) {
|
||
const target = pendingSentenceQueue.find(
|
||
(item) =>
|
||
item.id === pendingSentenceId && item.epoch === messageEpoch
|
||
);
|
||
if (!target) {
|
||
return;
|
||
}
|
||
|
||
target.correctedText = finalText || target.rawText || "";
|
||
target.processed = true;
|
||
target.correctionRequested = false;
|
||
flushProcessedPendingSentences();
|
||
}
|
||
async function record() {
|
||
// rec.open(function () {
|
||
// rec.start();
|
||
// });
|
||
// await startRecording();
|
||
await openMixedStream();
|
||
}
|
||
async function record2() {
|
||
// rec2.open(function () {
|
||
// rec2.start();
|
||
// });
|
||
// await startRecording();
|
||
await openMixedStream2();
|
||
}
|
||
//------------------- 实时 ASR online PCM 发送通道 start------------------
|
||
// rec 和 rec2 采集的是同一份混合音频,PCM 重采样和 960-sample 切片逻辑一致。
|
||
// rec 只负责 online 链路:wsconnecter.wsStart() 不传 URL 参数,
|
||
// wsconnecter.js 会在握手 JSON 中发送 mode:"online",用于页面实时预览文本。
|
||
// ********************每收到一帧音频时的回调********************
|
||
function recProcess(
|
||
buffer, // Recorder 已经采集到的 PCM 缓冲数组,最后一项是本次新增的音频片段
|
||
|
||
powerLevel, // 当前音量强度,当前逻辑没有使用
|
||
bufferDuration, // 已录制的总时长,当前逻辑没有使用
|
||
bufferSampleRate, // 浏览器采集到的原始采样率,常见是 48000Hz
|
||
newBufferIdx, // 本次新增音频片段在 buffer 中的起始索引,当前逻辑没有使用
|
||
asyncEnd // 异步处理结束回调,当前逻辑没有使用
|
||
) {
|
||
// 调试断点:打开浏览器开发者工具时,会在每次音频回调处暂停
|
||
// 只有处于录音状态时才处理音频,避免停止后旧回调继续发送数据
|
||
if (isRec === true) {
|
||
// 取出本次新增的最后一段 PCM 音频数据
|
||
var data_48k = buffer[buffer.length - 1];
|
||
|
||
// Recorder.SampleData 需要传入“音频片段数组”,所以把单段数据包一层数组
|
||
var array_48k = new Array(data_48k);
|
||
// 将浏览器原始采样率的音频重采样到 16000Hz,符合 ASR 服务输入要求
|
||
var data_16k = Recorder.SampleData(
|
||
array_48k,
|
||
bufferSampleRate,
|
||
16000
|
||
).data;
|
||
|
||
// 把新来的 16k PCM 数据追加到本地待发送缓冲区
|
||
sampleBuf = Int16Array.from([...sampleBuf, ...data_16k]);
|
||
|
||
// 如果缓冲超过硬限制,说明发送速度跟不上采集速度,需要丢掉最旧的数据
|
||
if (sampleBuf.length > asrBufferHardLimitSamples) {
|
||
// 计算本次超过硬限制的 sample 数量;Int16Array 每个 sample 约占 2 字节
|
||
const overflowSamples =
|
||
sampleBuf.length - asrBufferHardLimitSamples;
|
||
console.warn(
|
||
`⚠️ [Online] 丢弃过量本地音频缓冲: ${overflowSamples} samples`
|
||
);
|
||
// 只保留最后 asrBufferHardLimitSamples 个 sample,尽量保留最新的声音
|
||
sampleBuf = sampleBuf.slice(-asrBufferHardLimitSamples);
|
||
}
|
||
|
||
// 只要缓冲区够一个 ASR 分片,就持续切片发送
|
||
while (sampleBuf.length >= asrChunkSize) {
|
||
// 从缓冲区头部取一个固定大小的音频块,发送给在线识别 WebSocket
|
||
const sendBuf = sampleBuf.slice(0, asrChunkSize);
|
||
const sendAccepted = wsconnecter.wsSend(sendBuf);
|
||
// 如果 WebSocket 当前不可发送,保留剩余缓冲,等待下一次 onProcess 再尝试
|
||
if (!sendAccepted) {
|
||
break;
|
||
}
|
||
// 发送成功后,从本地缓冲中移除已经发送的这一段
|
||
sampleBuf = sampleBuf.slice(asrChunkSize);
|
||
}
|
||
|
||
// 超过软告警阈值时只打印警告,不丢数据;真正丢弃由上面的硬限制控制
|
||
if (sampleBuf.length > asrBufferWarnSamples) {
|
||
console.warn(
|
||
`⚠️ [Online] 音频缓冲区溢出: ${sampleBuf.length} samples (${(
|
||
sampleBuf.length / 16000
|
||
).toFixed(2)}s)`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
//--------------------实时 ASR online PCM 发送通道 end------------------
|
||
//------------------- 实时 ASR offline PCM 发送通道 start------------------
|
||
// rec2 与 rec 使用同样的 PCM 切片方式,但发送到独立的 wsconnecter2。
|
||
// wsconnecter2.wsStart(urls) 会在握手 JSON 中发送 mode:"offline",
|
||
// 用于 2pass-offline 正式落句、offline-speaker 说话人回填和后续 LLM 矫正。
|
||
function recProcess2(
|
||
buffer,
|
||
powerLevel,
|
||
bufferDuration,
|
||
bufferSampleRate,
|
||
newBufferIdx,
|
||
asyncEnd
|
||
) {
|
||
if (isRec === true) {
|
||
var data_48k = buffer[buffer.length - 1];
|
||
|
||
var array_48k = new Array(data_48k);
|
||
var data_16k = Recorder.SampleData(
|
||
array_48k,
|
||
bufferSampleRate,
|
||
16000
|
||
).data;
|
||
|
||
sampleBuf2 = Int16Array.from([...sampleBuf2, ...data_16k]);
|
||
|
||
if (sampleBuf2.length > asrBufferHardLimitSamples) {
|
||
const overflowSamples =
|
||
sampleBuf2.length - asrBufferHardLimitSamples;
|
||
console.warn(
|
||
`⚠️ [Offline] 丢弃过量本地音频缓冲: ${overflowSamples} samples`
|
||
);
|
||
sampleBuf2 = sampleBuf2.slice(-asrBufferHardLimitSamples);
|
||
}
|
||
|
||
while (sampleBuf2.length >= asrChunkSize) {
|
||
const sendBuf2 = sampleBuf2.slice(0, asrChunkSize);
|
||
const sendAccepted = wsconnecter2.wsSend(sendBuf2);
|
||
if (!sendAccepted) {
|
||
break;
|
||
}
|
||
sampleBuf2 = sampleBuf2.slice(asrChunkSize);
|
||
}
|
||
|
||
if (sampleBuf2.length > asrBufferWarnSamples) {
|
||
console.warn(
|
||
`⚠️ [Offline] 音频缓冲区溢出: ${sampleBuf2.length} samples (${(
|
||
sampleBuf2.length / 16000
|
||
).toFixed(2)}s)`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
//--------------------实时 ASR offline PCM 发送通道 end------------------
|
||
function addNewlineAfterPeriod(str) {
|
||
return str.replace(/[。?!]/g, "$&\n");
|
||
}
|
||
|
||
function handleWithTimestamp(
|
||
tmptext,
|
||
tmptime,
|
||
keywordStr,
|
||
timeThreshold
|
||
) {
|
||
if (!tmptime || tmptext.length <= 0) return tmptext;
|
||
|
||
const keywordList = keywordStr
|
||
.split(",")
|
||
.map((k) => k.trim())
|
||
.filter((k) => k.length > 0);
|
||
|
||
const keywordsAsRegex = keywordList.map((k) => new RegExp(k));
|
||
const segments = tmptext.match(/[^。!?]+[。!?]?/g) || [];
|
||
// console.log(segments, "segments");
|
||
|
||
const jsontime = JSON.parse(tmptime);
|
||
let char_index = 0;
|
||
let text_withtime = "";
|
||
|
||
// 辅助函数:去除新行开头的标点符号
|
||
function removeLeadingPunctuation(text) {
|
||
// 匹配开头的标点符号(中文和英文标点)
|
||
return text.replace(/^[,。!?、;:,\.!?;:]+/, "");
|
||
}
|
||
|
||
for (let i = 0; i < segments.length; i++) {
|
||
let seg = segments[i];
|
||
if (!seg || seg === "undefined") continue;
|
||
|
||
let nowTime = jsontime[char_index][0] / 1000;
|
||
|
||
if (
|
||
lastTimer.value > 0 &&
|
||
nowTime - lastTimer.value > timeThreshold
|
||
) {
|
||
const lastChar = text_withtime.slice(-1);
|
||
const endsWithPunc = /[。!?]/.test(lastChar);
|
||
// 换行后,如果新行开头是标点,去除第一个标点
|
||
let processedSeg = removeLeadingPunctuation(seg);
|
||
text_withtime +=
|
||
(endsWithPunc ? "\n" : "。\n") + processedSeg;
|
||
} else {
|
||
let hitKeyword = keywordsAsRegex.some((reg) => reg.test(seg));
|
||
if (hitKeyword) {
|
||
// 关键词后换行,如果下一行开头是标点,去除第一个标点
|
||
let processedSeg = removeLeadingPunctuation(seg);
|
||
// text_withtime += processedSeg + "。" + "\n";
|
||
} else {
|
||
text_withtime += seg;
|
||
}
|
||
}
|
||
|
||
lastTimer.value = nowTime;
|
||
char_index++;
|
||
}
|
||
|
||
return text_withtime;
|
||
}
|
||
function getSpeakerBeforeIndex(endIndex) {
|
||
let previousSpeaker = null;
|
||
for (let i = 0; i < endIndex; i++) {
|
||
if (segmentList[i] && segmentList[i].speaker) {
|
||
previousSpeaker = segmentList[i].speaker;
|
||
}
|
||
}
|
||
return previousSpeaker;
|
||
}
|
||
|
||
function buildSegmentText(segments, initialSpeaker = null) {
|
||
let displayText = "";
|
||
let lastRenderedSpeaker = initialSpeaker;
|
||
|
||
segments.forEach((seg) => {
|
||
if (showSpeaker.value) {
|
||
if (
|
||
seg.speaker &&
|
||
seg.speaker !== lastRenderedSpeaker &&
|
||
!seg.speaker.includes("SPK")
|
||
) {
|
||
displayText +=
|
||
(displayText ? "\n" : "") + seg.speaker + ":\n";
|
||
lastRenderedSpeaker = seg.speaker;
|
||
} else if (
|
||
seg.speaker &&
|
||
seg.speaker !== lastRenderedSpeaker
|
||
) {
|
||
displayText +=
|
||
(displayText ? "\n" : "") + seg.speaker + ":\n";
|
||
lastRenderedSpeaker = seg.speaker;
|
||
}
|
||
}
|
||
|
||
let textContent = handleWithTimestamp(
|
||
seg.text,
|
||
seg.timestamp,
|
||
configwords,
|
||
6
|
||
);
|
||
displayText += textContent;
|
||
});
|
||
|
||
return displayText;
|
||
}
|
||
|
||
function renderFullText() {
|
||
const initialSpeaker =
|
||
segmentRenderStartIndex > 0
|
||
? getSpeakerBeforeIndex(segmentRenderStartIndex)
|
||
: null;
|
||
const displayText = buildSegmentText(
|
||
segmentList.slice(segmentRenderStartIndex),
|
||
initialSpeaker
|
||
);
|
||
|
||
rec_text = displayText;
|
||
// 保留历史转录内容(来自暂停/继续草稿)
|
||
asrResult.value = pausedTranscription.value + rec_text; // 更新主显示区域
|
||
|
||
// 更新实时显示,展示剩余的待处理句子 + 当前正在识别的内容
|
||
syncRealtimePreview();
|
||
// 移除旧的清空逻辑
|
||
// asrResultOnline.value = "";
|
||
// rec_textOnline = "";
|
||
}
|
||
// Legacy implementation kept temporarily for reference; the active
|
||
// realtime path uses the clean getJsonMessage defined below.
|
||
function getJsonMessageLegacy(jsonMsg) {
|
||
const parsedMessage = JSON.parse(jsonMsg.data);
|
||
var rectxt = "" + parsedMessage["text"];
|
||
var asrmodel = parsedMessage["mode"];
|
||
var timestamp = parsedMessage["timestamp"];
|
||
|
||
// 检查是否需要重置转录状态(从暂停/恢复时)
|
||
if (resetTranscriptionFlag) {
|
||
console.log("🔄 重置转录状态(暂停/恢复后)");
|
||
clearRealtimePipeline();
|
||
resetTranscriptionFlag = false;
|
||
}
|
||
|
||
const messageEpoch = liveTranscriptionEpoch;
|
||
|
||
if (asrmodel == "2pass-online") {
|
||
// ✅ 实时转录换行逻辑
|
||
// 规则1:凡是换行的,末尾必须是句号(。)
|
||
// 规则2:不修改标点符号
|
||
// 规则3:停顿3秒以上才换行
|
||
|
||
// 解析时间戳(获取最后一个字符的时间)
|
||
const currentTimestamp = extractTimestampMs(timestamp);
|
||
if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
|
||
syncRealtimePreview();
|
||
if (is_final.value == true) {
|
||
wsconnecter.wsStop();
|
||
webReader2.wsStop();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 检查是否需要换行
|
||
let shouldBreak = false;
|
||
if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
|
||
// 计算时间差(单位:秒)
|
||
const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
|
||
// 停顿超过3秒,且当前文本以句号结尾
|
||
if (timeDiff >= 3 && /。\s*$/.test(rectxt)) {
|
||
shouldBreak = true;
|
||
}
|
||
}
|
||
|
||
// 拼接文本(不修改标点)
|
||
if (shouldBreak) {
|
||
rec_textOnline = rec_textOnline + rectxt + "\n";
|
||
} else {
|
||
rec_textOnline = rec_textOnline + rectxt;
|
||
}
|
||
|
||
// 更新时间戳
|
||
lastOnlineTimestamp = currentTimestamp;
|
||
|
||
} else if (asrmodel == "2pass-offline") {
|
||
// Bug fix: 使用offline返回的完整文本替代可能不完整的online累积文本
|
||
// rectxt包含了完整的转录结果,比rec_textOnline更准确
|
||
ensurePendingSentence(rectxt, timestamp, messageEpoch);
|
||
rec_textOnline = "";
|
||
lastOnlineTimestamp = null; // 重置时间戳
|
||
} else{
|
||
}
|
||
// 更新显示:队列中的内容 + 当前实时内容
|
||
syncRealtimePreview();
|
||
|
||
if (is_final.value == true) {
|
||
wsconnecter.wsStop();
|
||
webReader2.wsStop();
|
||
}
|
||
}
|
||
function getJsonMessage(jsonMsg) {
|
||
const parsedMessage = JSON.parse(jsonMsg.data);
|
||
var rectxt = "" + parsedMessage["text"];
|
||
var asrmodel = parsedMessage["mode"];
|
||
var timestamp = parsedMessage["timestamp"];
|
||
|
||
if (resetTranscriptionFlag) {
|
||
console.log("重置实时转录状态(暂停/恢复后)");
|
||
clearRealtimePipeline();
|
||
resetTranscriptionFlag = false;
|
||
}
|
||
|
||
const messageEpoch = liveTranscriptionEpoch;
|
||
|
||
if (asrmodel == "2pass-online") {
|
||
const currentTimestamp = extractTimestampMs(timestamp);
|
||
if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
|
||
syncRealtimePreview();
|
||
if (is_final.value == true) {
|
||
wsconnecter.wsStop();
|
||
webReader2.wsStop();
|
||
}
|
||
return;
|
||
}
|
||
|
||
let shouldBreak = false;
|
||
if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
|
||
const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
|
||
if (timeDiff >= 3 && /。\s*$/.test(rectxt)) {
|
||
shouldBreak = true;
|
||
}
|
||
}
|
||
|
||
if (shouldBreak) {
|
||
rec_textOnline = rec_textOnline + rectxt + "\n";
|
||
} else {
|
||
rec_textOnline = rec_textOnline + rectxt;
|
||
}
|
||
|
||
lastOnlineTimestamp = currentTimestamp;
|
||
} else if (asrmodel == "2pass-offline") {
|
||
// 离线句子的正式入队只走 getJsonMessage2,避免双通路重复落句。
|
||
rec_textOnline = "";
|
||
offlineSentenceHandoffText = rectxt || rec_textOnline || "";
|
||
rec_textOnline = "";
|
||
lastOnlineTimestamp = null;
|
||
}
|
||
|
||
syncRealtimePreview();
|
||
|
||
if (is_final.value == true) {
|
||
wsconnecter.wsStop();
|
||
webReader2.wsStop();
|
||
}
|
||
}
|
||
async function getJsonMessage2(jsonMsg) {
|
||
const parsedMessage = JSON.parse(jsonMsg.data);
|
||
var asrmodeltype = parsedMessage["mode"];
|
||
var timestamp = parsedMessage["timestamp"];
|
||
const messageEpoch = liveTranscriptionEpoch;
|
||
const messageTimestampMs = extractTimestampMs(timestamp); // 获取最后一个时间
|
||
|
||
// ============================
|
||
// 场景 A: 2pass-offline (文本先到)
|
||
// ============================
|
||
if (asrmodeltype == "2pass-offline") {
|
||
if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
|
||
if (is_final.value == true) {
|
||
wsconnecter.wsStop();
|
||
}
|
||
return;
|
||
}
|
||
|
||
var rectxt = "" + parsedMessage["text"];
|
||
rec_textOnline = "";
|
||
lastOnlineTimestamp = null;
|
||
// 3. 准备进行 LLM 矫正
|
||
// 使用当前数组里已有的文本 (就是 2pass-offline 的文本)
|
||
const pendingSentence = ensurePendingSentence(
|
||
rectxt,
|
||
timestamp,
|
||
messageEpoch
|
||
);
|
||
offlineSentenceHandoffText = "";
|
||
syncRealtimePreview();
|
||
if (
|
||
!pendingSentence ||
|
||
pendingSentence.processed ||
|
||
pendingSentence.correctionRequested
|
||
) {
|
||
return;
|
||
}
|
||
|
||
pendingSentence.correctionRequested = true;
|
||
pendingCorrectionCount++;
|
||
try {
|
||
const response = await fetch(`/tool/speakr/api/asr/correct`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ text: pendingSentence.rawText }), // 发送当前新增文本去矫正
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error("ASR correction failed");
|
||
}
|
||
|
||
const data = await response.json();
|
||
let correctedText =
|
||
data.corrected_text || pendingSentence.rawText;
|
||
|
||
// 4. LLM 回来后,直接更新文本内容(自动应用)
|
||
finalizePendingSentence(
|
||
pendingSentence.id,
|
||
correctedText,
|
||
messageEpoch
|
||
);
|
||
console.log("LLM矫正完成");
|
||
} catch (error) {
|
||
finalizePendingSentence(
|
||
pendingSentence.id,
|
||
pendingSentence.rawText,
|
||
messageEpoch
|
||
);
|
||
console.error("LLM矫正出错:", error);
|
||
} finally {
|
||
pendingCorrectionCount = Math.max(
|
||
0,
|
||
pendingCorrectionCount - 1
|
||
);
|
||
}
|
||
}
|
||
|
||
// ============================
|
||
// 场景 B: offline-speaker (说话人后到)
|
||
// ============================
|
||
else if (asrmodeltype == "offline-speaker") {
|
||
if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
|
||
if (is_final.value == true) {
|
||
wsconnecter.wsStop();
|
||
}
|
||
return;
|
||
}
|
||
// 【修改点】:这里不再获取 full_text 用于替换
|
||
// var rectxt = "" + JSON.parse(jsonMsg.data)["full_text"]; // 这行删掉或注释
|
||
|
||
let nowSpeaker =
|
||
(parsedMessage["segments"] &&
|
||
parsedMessage["segments"][0] &&
|
||
parsedMessage["segments"][0].speaker) ||
|
||
"未命名演讲者";
|
||
|
||
// 找到最后一个还没认领说话人的句子
|
||
let targetIndex = -1;
|
||
for (let i = segmentList.length - 1; i >= 0; i--) {
|
||
const segment = segmentList[i];
|
||
if (!segment || segment.epoch !== messageEpoch) {
|
||
continue;
|
||
}
|
||
if (
|
||
segment.speaker === null &&
|
||
Number.isFinite(messageTimestampMs) &&
|
||
Number.isFinite(segment.timestampMs) &&
|
||
segment.timestampMs === messageTimestampMs
|
||
) {
|
||
targetIndex = i;
|
||
break;
|
||
}
|
||
if (targetIndex === -1 && segment.speaker === null) {
|
||
targetIndex = i;
|
||
}
|
||
}
|
||
|
||
if (targetIndex !== -1) {
|
||
// 1. 只更新说话人
|
||
segmentList[targetIndex].speaker = nowSpeaker;
|
||
|
||
// 【关键】:这里绝对不更新 text,保持 2pass-offline 时的原样
|
||
// segmentList[targetIndex].text = ...; // 不要这行
|
||
|
||
// 2. 立即渲染
|
||
// 效果:文本没变(不会跳变),只是头上多了个名字
|
||
renderFullText();
|
||
}
|
||
}
|
||
|
||
if (is_final.value == true) {
|
||
wsconnecter.wsStop();
|
||
}
|
||
}
|
||
|
||
// 连接状态响应
|
||
function getConnState(connState) {
|
||
if (connState === 0) {
|
||
record();
|
||
} else if (connState === 1) {
|
||
} else if (connState === 2) {
|
||
stop();
|
||
}
|
||
}
|
||
// 连接状态响应
|
||
function getConnState2(connState) {
|
||
if (connState === 0) {
|
||
record2();
|
||
} else if (connState === 1) {
|
||
} else if (connState === 2) {
|
||
stop();
|
||
}
|
||
}
|
||
async function getMicrophoneInfo() {
|
||
if (
|
||
!navigator.mediaDevices ||
|
||
!navigator.mediaDevices.enumerateDevices
|
||
) {
|
||
console.warn("当前浏览器不支持获取设备信息");
|
||
return null;
|
||
}
|
||
|
||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||
const mics = devices.filter((d) => d.kind === "audioinput");
|
||
|
||
// 当前默认麦克风(大多数浏览器)
|
||
const defaultMic =
|
||
mics.find((d) => d.deviceId === "default") || mics[0];
|
||
|
||
return {
|
||
name: defaultMic?.label || "未知麦克风",
|
||
deviceId: defaultMic?.deviceId || "",
|
||
all: mics.map((d) => ({
|
||
name: d.label,
|
||
deviceId: d.deviceId,
|
||
})),
|
||
};
|
||
}
|
||
async function openMixedStream() {
|
||
try {
|
||
const micInfo = await getMicrophoneInfo();
|
||
if (micInfo) {
|
||
console.log("🎤 当前麦克风:", micInfo.name, micInfo);
|
||
}
|
||
// 获取麦克风音频流
|
||
const micStreams = micStream;
|
||
|
||
// 尝试获取系统音频流
|
||
let systemStreams;
|
||
try {
|
||
systemStreams = systemStream;
|
||
} catch (err) {
|
||
console.warn("未能获取系统音频,仅使用麦克风录音:", err);
|
||
}
|
||
|
||
// 创建音频上下文和混音目标
|
||
if (asrAudioContext) {
|
||
try { asrAudioContext.close(); } catch(e) {}
|
||
}
|
||
asrAudioContext = new (window.AudioContext ||
|
||
window.webkitAudioContext)();
|
||
const audioContext = asrAudioContext;
|
||
const destination = audioContext.createMediaStreamDestination();
|
||
|
||
// 把麦克风接入混音输出
|
||
const micSource =
|
||
audioContext.createMediaStreamSource(micStreams);
|
||
micSource.connect(destination);
|
||
|
||
// 如果有系统音频,混入
|
||
if (systemStream && systemStream.getAudioTracks().length > 0) {
|
||
const sysSource =
|
||
audioContext.createMediaStreamSource(systemStream);
|
||
sysSource.connect(destination);
|
||
} else {
|
||
console.log("⚠️ 未检测到系统音频,使用麦克风录音");
|
||
}
|
||
|
||
// 打开 recorder 并传入混合后的流
|
||
rec.open(
|
||
() => {
|
||
rec.start(destination.stream);
|
||
},
|
||
(msg) => {
|
||
console.error("❌ 录音权限被拒绝:", msg);
|
||
}
|
||
);
|
||
} catch (err) {
|
||
console.error("初始化录音失败:", err);
|
||
}
|
||
}
|
||
async function openMixedStream2() {
|
||
try {
|
||
// 获取麦克风音频流
|
||
const micStreams = micStream;
|
||
|
||
// 尝试获取系统音频流(Chrome 需要用户手动勾选“共享系统音频”)
|
||
let systemStreams;
|
||
try {
|
||
systemStreams = systemStream;
|
||
} catch (err) {
|
||
console.warn("未能获取系统音频,仅使用麦克风录音:", err);
|
||
}
|
||
|
||
// 创建音频上下文和混音目标
|
||
if (asrAudioContext2) {
|
||
try { asrAudioContext2.close(); } catch(e) {}
|
||
}
|
||
asrAudioContext2 = new (window.AudioContext ||
|
||
window.webkitAudioContext)();
|
||
const audioContext = asrAudioContext2;
|
||
const destination = audioContext.createMediaStreamDestination();
|
||
|
||
// 把麦克风接入混音输出
|
||
const micSource =
|
||
audioContext.createMediaStreamSource(micStreams);
|
||
micSource.connect(destination);
|
||
|
||
// 如果有系统音频,混入
|
||
if (systemStream && systemStream.getAudioTracks().length > 0) {
|
||
const sysSource =
|
||
audioContext.createMediaStreamSource(systemStream);
|
||
sysSource.connect(destination);
|
||
} else {
|
||
console.log("⚠️ 未检测到系统音频,使用麦克风录音");
|
||
}
|
||
|
||
// 打开 recorder 并传入混合后的流
|
||
rec2.open(
|
||
() => {
|
||
rec2.start(destination.stream);
|
||
},
|
||
(msg) => {
|
||
console.error("❌ 录音权限被拒绝:", msg);
|
||
}
|
||
);
|
||
} catch (err) {
|
||
console.error("初始化录音失败:", err);
|
||
}
|
||
}
|
||
|
||
//------------------- 实时 ASR 双 WebSocket 启动 start------------------
|
||
const start = () => {
|
||
// 清除显示
|
||
// clear();
|
||
//控件状态更新
|
||
|
||
//启动 online 连接:不传 URL 时仍连接默认 /websocket_offline,
|
||
//真正区别是 wsconnecter.js 将 modeType 设置为 "online"。
|
||
var ret = wsconnecter.wsStart();
|
||
if (ret == 1) {
|
||
isRec = true;
|
||
return 1;
|
||
} else {
|
||
return 0;
|
||
}
|
||
};
|
||
const start2 = () => {
|
||
// 清除显示
|
||
// clear();
|
||
//控件状态更新
|
||
|
||
let urls = `wss://${wssBaseUrl.FUNASR_WEBSOCKET_IP}/websocket_offline`;
|
||
//启动 offline 连接:URL 与 online 默认地址相同,区别在握手 mode:"offline"。
|
||
var ret = wsconnecter2.wsStart(urls);
|
||
if (ret == 1) {
|
||
isRec = true;
|
||
return 1;
|
||
} else {
|
||
return 0;
|
||
}
|
||
};
|
||
//--------------------实时 ASR 双 WebSocket 启动 end------------------
|
||
if (!isPaused.value) {
|
||
start();
|
||
start2();
|
||
}
|
||
//------------------- 实时 ASR 双 Recorder 实例 start------------------
|
||
// 这里的 rec/rec2 只负责实时 ASR PCM 推流,不是最终保存录音文件的 MediaRecorder。
|
||
// 两个 Recorder 都监听同一份混合音频流,分别进入 online/offline 两条 WebSocket 链路。
|
||
rec = Recorder({
|
||
type: "pcm",
|
||
bitRate: 16,
|
||
sampleRate: 16000,
|
||
onProcess: recProcess,
|
||
});
|
||
rec2 = Recorder({
|
||
type: "pcm",
|
||
bitRate: 16,
|
||
sampleRate: 16000,
|
||
onProcess: recProcess2,
|
||
});
|
||
//--------------------实时 ASR 双 Recorder 实例 end------------------
|
||
|
||
// Fix audio duration for recorded files
|
||
if (audioBlobURL.value) {
|
||
const recordingAudio = document.querySelector(
|
||
'audio[src*="blob:"]'
|
||
);
|
||
if (recordingAudio) {
|
||
const fixDuration = () => {
|
||
if (
|
||
!isFinite(recordingAudio.duration) ||
|
||
recordingAudio.duration === 0
|
||
) {
|
||
|
||
const originalTime = recordingAudio.currentTime;
|
||
recordingAudio.currentTime = 1e101;
|
||
recordingAudio.addEventListener(
|
||
"timeupdate",
|
||
function resetTime() {
|
||
recordingAudio.currentTime = originalTime;
|
||
recordingAudio.removeEventListener(
|
||
"timeupdate",
|
||
resetTime
|
||
);
|
||
},
|
||
{ once: true }
|
||
);
|
||
}
|
||
};
|
||
recordingAudio.addEventListener("loadedmetadata", fixDuration, {
|
||
once: true,
|
||
});
|
||
recordingAudio.addEventListener("canplay", fixDuration, {
|
||
once: true,
|
||
});
|
||
if (recordingAudio.readyState >= 1) {
|
||
setTimeout(fixDuration, 100);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
liveTranscriptionEpoch += 1;
|
||
realtimeTimestampBarrierMs = -1;
|
||
clearRealtimePipelineRef = null;
|
||
getLatestRealtimeTimestampRef = null;
|
||
buildRealtimeEditCarryoverRef = null;
|
||
renderFullTextRef = null;
|
||
isRec = false;
|
||
analysisResult.value = "";
|
||
asrResult.value = "";
|
||
segmentList = [];
|
||
segmentRenderStartIndex = 0;
|
||
isEditingTranscription.value = false;
|
||
is_final.value = false;
|
||
if (recordingMarkdownEditorInstance.value) {
|
||
recordingMarkdownEditorInstance.value.toTextArea();
|
||
recordingMarkdownEditorInstance.value = null;
|
||
}
|
||
if (webReader) {
|
||
webReader.wsStop();
|
||
}
|
||
if (webReader2) {
|
||
webReader2.wsStop();
|
||
}
|
||
// 关闭 Recorder 实例,停止 onProcess 回调
|
||
if (rec) {
|
||
try { rec.close(); } catch(e) {}
|
||
rec = null;
|
||
}
|
||
if (rec2) {
|
||
try { rec2.close(); } catch(e) {}
|
||
rec2 = null;
|
||
}
|
||
if (asrAudioContext) {
|
||
try { asrAudioContext.close(); } catch(e) {}
|
||
asrAudioContext = null;
|
||
}
|
||
if (asrAudioContext2) {
|
||
try { asrAudioContext2.close(); } catch(e) {}
|
||
asrAudioContext2 = null;
|
||
}
|
||
|
||
// Clean up recording markdown editor when leaving recording view
|
||
}
|
||
});
|
||
//--------------------录音视图切换与实时 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,
|
||
].forEach(scrollRealtimeTextareaToBottom);
|
||
};
|
||
const scheduleRealtimeAutoScroll = async (force = false) => {
|
||
if (isEditingTranscription.value && !force) {
|
||
return;
|
||
}
|
||
|
||
await nextTick();
|
||
scrollVisibleRealtimeTextareas();
|
||
|
||
if (
|
||
typeof window === "undefined" ||
|
||
typeof window.requestAnimationFrame !== "function"
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (realtimeAutoScrollFrame !== null) {
|
||
window.cancelAnimationFrame(realtimeAutoScrollFrame);
|
||
realtimeAutoScrollFrame = null;
|
||
}
|
||
|
||
realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
|
||
scrollVisibleRealtimeTextareas();
|
||
realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
|
||
scrollVisibleRealtimeTextareas();
|
||
realtimeAutoScrollFrame = null;
|
||
});
|
||
});
|
||
};
|
||
watch(fullRealtimeTranscription, () => {
|
||
scheduleRealtimeAutoScroll();
|
||
});
|
||
watch(isEditingTranscription, () => {
|
||
scheduleRealtimeAutoScroll(true);
|
||
});
|
||
watch(isyjDialogVisible, (visible) => {
|
||
if (visible) {
|
||
scheduleRealtimeAutoScroll(true);
|
||
}
|
||
});
|
||
watch(analysisResult, async () => {
|
||
await nextTick(); // 等 DOM 更新完再操作
|
||
if (sumupResultTextarea.value) {
|
||
sumupResultTextarea.value.scrollTop =
|
||
sumupResultTextarea.value.scrollHeight;
|
||
}
|
||
});
|
||
watch(audioBlobURL, (newURL) => {
|
||
if (newURL) {
|
||
nextTick(() => {
|
||
const recordingAudio = document.querySelector(
|
||
'audio[src*="blob:"]'
|
||
);
|
||
|
||
if (recordingAudio) {
|
||
const fixDuration = () => {
|
||
if (
|
||
!isFinite(recordingAudio.duration) ||
|
||
recordingAudio.duration === 0
|
||
) {
|
||
const originalTime = recordingAudio.currentTime;
|
||
// recordingAudio.currentTime = 1e101;
|
||
recordingAudio.currentTime = originalTime + 0.01
|
||
|
||
recordingAudio.addEventListener(
|
||
"timeupdate",
|
||
function resetTime() {
|
||
recordingAudio.currentTime = originalTime;
|
||
recordingAudio.removeEventListener(
|
||
"timeupdate",
|
||
resetTime
|
||
);
|
||
},
|
||
{ once: true }
|
||
);
|
||
}
|
||
};
|
||
|
||
// Multiple events to ensure we catch the duration
|
||
recordingAudio.addEventListener("loadedmetadata", fixDuration, {
|
||
once: true,
|
||
});
|
||
recordingAudio.addEventListener("loadeddata", fixDuration, {
|
||
once: true,
|
||
});
|
||
recordingAudio.addEventListener("canplay", fixDuration, {
|
||
once: true,
|
||
});
|
||
|
||
// Also try after a delay
|
||
if (recordingAudio.readyState >= 1) {
|
||
setTimeout(fixDuration, 100);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
watch(playerVolume, (newVolume) => {
|
||
const audioElements = document.querySelectorAll("audio");
|
||
audioElements.forEach((audio) => {
|
||
if (audio.volume !== newVolume) {
|
||
audio.volume = newVolume;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Watch for search query changes
|
||
watch(searchQuery, (newQuery) => {
|
||
debouncedSearch(newQuery);
|
||
});
|
||
|
||
// Auto-apply filters when they change (except text query which is debounced)
|
||
watch(
|
||
filterTags,
|
||
() => {
|
||
applyAdvancedFilters();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
watch(filterDatePreset, () => {
|
||
applyAdvancedFilters();
|
||
});
|
||
|
||
watch(
|
||
filterDateRange,
|
||
() => {
|
||
applyAdvancedFilters();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// Debounce text query changes
|
||
watch(filterTextQuery, (newValue) => {
|
||
clearTimeout(searchDebounceTimer.value);
|
||
searchDebounceTimer.value = setTimeout(() => {
|
||
applyAdvancedFilters();
|
||
}, 300);
|
||
});
|
||
|
||
//--------------------响应式监听与界面联动 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);
|
||
});
|
||
|
||
//--------------------生命周期初始化 end------------------
|
||
|
||
//------------------- 实时转写手动编辑模式 start------------------
|
||
// --- 转录文本编辑功能 ---
|
||
// 用户手动编辑后,以编辑结果为准,旧的实时结果不再回灌。
|
||
let editModeOriginalText = "";
|
||
|
||
function enterEditMode() {
|
||
editModeOriginalText = fullRealtimeTranscription.value || "";
|
||
editableText.value = editModeOriginalText;
|
||
editModeBufferStartIndex = segmentList.length;
|
||
const barrierTimestampMs = getLatestRealtimeTimestampRef
|
||
? getLatestRealtimeTimestampRef()
|
||
: null;
|
||
startNewRealtimeEpoch({
|
||
barrierTimestampMs,
|
||
clearEditCarryover: false,
|
||
});
|
||
realtimeEditCarryover = buildRealtimeEditCarryoverRef
|
||
? buildRealtimeEditCarryoverRef(editModeOriginalText)
|
||
: null;
|
||
isEditingTranscription.value = true;
|
||
}
|
||
|
||
function exitEditMode() {
|
||
const nextText = editableText.value ?? "";
|
||
|
||
isEditingTranscription.value = false;
|
||
|
||
pausedTranscription.value = nextText;
|
||
asrResult.value = nextText;
|
||
asrResultOnline.value = "";
|
||
asrResultOffline.value = "";
|
||
segmentRenderStartIndex = editModeBufferStartIndex;
|
||
editModeOriginalText = nextText;
|
||
|
||
if (renderFullTextRef) {
|
||
renderFullTextRef();
|
||
}
|
||
}
|
||
|
||
//--------------------实时转写手动编辑模式 end------------------
|
||
|
||
//------------------- 模板暴露与事件出口 start------------------
|
||
return {
|
||
// Core State
|
||
currentView,
|
||
dragover,
|
||
recordings,
|
||
selectedRecording,
|
||
selectedTab,
|
||
searchQuery,
|
||
isLoadingRecordings,
|
||
globalError,
|
||
maxFileSizeMB,
|
||
chunkingEnabled,
|
||
chunkingMode,
|
||
chunkingLimit,
|
||
chunkingLimitDisplay,
|
||
sortBy,
|
||
showAdvancedFilters,
|
||
filterTags,
|
||
filterDateRange,
|
||
filterDatePreset,
|
||
filterTextQuery,
|
||
|
||
// Pagination State
|
||
currentPage,
|
||
perPage,
|
||
totalRecordings,
|
||
totalPages,
|
||
hasNextPage,
|
||
hasPrevPage,
|
||
isLoadingMore,
|
||
|
||
// UI State
|
||
browser,
|
||
isSidebarCollapsed,
|
||
searchTipsExpanded,
|
||
isUserMenuOpen,
|
||
isDarkMode,
|
||
currentColorScheme,
|
||
showColorSchemeModal,
|
||
windowWidth,
|
||
isMobileScreen,
|
||
mobileTab,
|
||
isMetadataExpanded,
|
||
|
||
// i18n State
|
||
currentLanguage,
|
||
currentLanguageName,
|
||
availableLanguages,
|
||
showLanguageMenu,
|
||
|
||
// Upload State
|
||
uploadQueue,
|
||
currentlyProcessingFile,
|
||
processingProgress,
|
||
processingMessage,
|
||
isProcessingActive,
|
||
progressPopupMinimized,
|
||
progressPopupClosed,
|
||
totalInQueue,
|
||
completedInQueue,
|
||
finishedFilesInQueue,
|
||
clearCompletedUploads,
|
||
|
||
// Audio Recording
|
||
editingDraftId,
|
||
editingDraftName,
|
||
|
||
isRecording,
|
||
canRecordAudio,
|
||
canRecordSystemAudio,
|
||
systemAudioSupported,
|
||
systemAudioError,
|
||
audioBlobURL,
|
||
recordingTime,
|
||
recordingNotes,
|
||
visualizer,
|
||
micVisualizer,
|
||
systemVisualizer,
|
||
recordingMode,
|
||
recordingDisclaimer,
|
||
showRecordingDisclaimerModal,
|
||
acceptRecordingDisclaimer,
|
||
cancelRecordingDisclaimer,
|
||
showAdvancedOptions,
|
||
uploadLanguage,
|
||
uploadMinSpeakers,
|
||
uploadMaxSpeakers,
|
||
asrLanguage,
|
||
asrMinSpeakers,
|
||
asrMaxSpeakers,
|
||
availableTags,
|
||
selectedTagIds,
|
||
selectedTags,
|
||
uploadTagSearchFilter,
|
||
filteredAvailableTagsForUpload,
|
||
onTagSelected,
|
||
addTagToSelection,
|
||
removeTagFromSelection,
|
||
showSystemAudioHelp,
|
||
|
||
// Draft/Pause Recording
|
||
isPaused,
|
||
isStoppingRecording,
|
||
currentDraftId,
|
||
showRecordingControls,
|
||
pausedDuration,
|
||
pausedTranscription,
|
||
draftRecordings,
|
||
draftsExpanded,
|
||
pauseRecording,
|
||
resumeRecording,
|
||
loadDrafts,
|
||
continueDraftRecording,
|
||
deleteDraft,
|
||
|
||
// Modal State
|
||
showEditModal,
|
||
showDeleteModal,
|
||
showResetModal,
|
||
editingRecording,
|
||
recordingToDelete,
|
||
showEditTagsModal,
|
||
selectedNewTagId,
|
||
tagSearchFilter,
|
||
filteredAvailableTagsForModal,
|
||
editRecordingTags,
|
||
closeEditTagsModal,
|
||
addTagToRecording,
|
||
removeTagFromRecording,
|
||
getRecordingTags,
|
||
getAvailableTagsForRecording,
|
||
filterByTag,
|
||
clearTagFilter,
|
||
applyAdvancedFilters,
|
||
clearAllFilters,
|
||
showTextEditorModal,
|
||
showAsrEditorModal,
|
||
editingTranscriptionContent,
|
||
editingSegments,
|
||
availableSpeakers,
|
||
|
||
// Inline Editing
|
||
editingParticipants,
|
||
editingMeetingDate,
|
||
editingSummary,
|
||
editingNotes,
|
||
|
||
// Markdown Editor
|
||
notesMarkdownEditor,
|
||
markdownEditorInstance,
|
||
summaryMarkdownEditor,
|
||
summaryMarkdownEditorInstance,
|
||
recordingNotesEditor,
|
||
|
||
// Transcription
|
||
transcriptionViewMode,
|
||
legendExpanded,
|
||
highlightedSpeaker,
|
||
processedTranscription,
|
||
|
||
// Chat
|
||
showChat,
|
||
isChatMaximized,
|
||
toggleChatMaximize,
|
||
chatMessages,
|
||
chatInput,
|
||
isChatLoading,
|
||
chatMessagesRef,
|
||
|
||
// Audio Player
|
||
playerVolume,
|
||
|
||
// Column Resizing
|
||
leftColumnWidth,
|
||
rightColumnWidth,
|
||
isResizing,
|
||
|
||
// App Configuration
|
||
useAsrEndpoint,
|
||
currentUserName,
|
||
|
||
// Computed
|
||
filteredRecordings,
|
||
groupedRecordings,
|
||
activeRecordingMetadata,
|
||
datePresetOptions,
|
||
languageOptions,
|
||
|
||
// Color Schemes
|
||
colorSchemes,
|
||
asrResult,
|
||
asrResultOnline,
|
||
asrResultOffline,
|
||
fullRealtimeTranscription,
|
||
asrTextarea,
|
||
asrResultTextarea,
|
||
asrResultTextareaOnline,
|
||
analysisResult,
|
||
sumupResultTextarea,
|
||
isDialogVisible,
|
||
isyjDialogVisible,
|
||
baseInfo,
|
||
isModalOpen,
|
||
urlinfo,
|
||
urlmsg,
|
||
zjloding,
|
||
// Methods
|
||
openmodel,
|
||
openzjmodel,
|
||
closeModal,
|
||
setGlobalError,
|
||
formatFileSize,
|
||
formatDisplayDate,
|
||
formatStatus,
|
||
getStatusClass,
|
||
formatTime,
|
||
t,
|
||
tc,
|
||
changeLanguage,
|
||
toggleDarkMode,
|
||
applyColorScheme,
|
||
initializeColorScheme,
|
||
openColorSchemeModal,
|
||
closeColorSchemeModal,
|
||
selectColorScheme,
|
||
resetColorScheme,
|
||
toggleSidebar,
|
||
switchToUploadView,
|
||
selectRecording,
|
||
handleDragOver,
|
||
handleDragLeave,
|
||
handleDrop,
|
||
handleFileSelect,
|
||
addFilesToQueue,
|
||
startRecording,
|
||
stopRecording,
|
||
uploadRecordedAudio,
|
||
discardRecording,
|
||
downloadRecordedAudio,
|
||
loadRecordings,
|
||
loadMoreRecordings,
|
||
performSearch,
|
||
debouncedSearch,
|
||
saveMetadata,
|
||
editRecording,
|
||
cancelEdit,
|
||
saveEdit,
|
||
confirmDelete,
|
||
cancelDelete,
|
||
deleteRecording,
|
||
toggleEditParticipants,
|
||
toggleEditMeetingDate,
|
||
toggleEditSummary,
|
||
cancelEditSummary,
|
||
saveEditSummary,
|
||
toggleEditNotes,
|
||
cancelEditNotes,
|
||
saveEditNotes,
|
||
initializeMarkdownEditor,
|
||
saveInlineEdit,
|
||
clickToEditNotes,
|
||
clickToEditSummary,
|
||
autoSaveNotes,
|
||
autoSaveSummary,
|
||
sendChatMessage,
|
||
isChatScrolledToBottom,
|
||
scrollChatToBottom,
|
||
startColumnResize,
|
||
handleChatKeydown,
|
||
seekAudio,
|
||
seekAudioFromEvent,
|
||
onPlayerVolumeChange,
|
||
showToast,
|
||
copyMessage,
|
||
copyTranscription,
|
||
copySummary,
|
||
copyNotes,
|
||
downloadSummary,
|
||
downloadTranscript,
|
||
downloadNotes,
|
||
downloadChat,
|
||
clearChat,
|
||
downloadEventICS,
|
||
downloadICS,
|
||
formatEventDateTime,
|
||
toggleInbox,
|
||
toggleHighlight,
|
||
toggleTranscriptionViewMode,
|
||
reprocessTranscription,
|
||
reprocessSummary,
|
||
generateSummary,
|
||
resetRecordingStatus,
|
||
confirmReset,
|
||
cancelReset,
|
||
executeReset,
|
||
openTranscriptionEditor,
|
||
openTextEditorModal,
|
||
closeTextEditorModal,
|
||
saveTranscription,
|
||
openAsrEditorModal,
|
||
closeAsrEditorModal,
|
||
saveAsrTranscription,
|
||
adjustTime,
|
||
filterSpeakers,
|
||
openSpeakerSuggestions,
|
||
closeSpeakerSuggestions,
|
||
selectSpeaker,
|
||
addSegment,
|
||
removeSegment,
|
||
showReprocessModal,
|
||
reprocessType,
|
||
reprocessRecording,
|
||
cancelReprocess,
|
||
executeReprocess,
|
||
asrReprocessOptions,
|
||
showSpeakerModal,
|
||
showShareModal,
|
||
recordingToShare,
|
||
shareOptions,
|
||
generatedShareLink,
|
||
existingShareDetected,
|
||
userShares,
|
||
isLoadingShares,
|
||
showSharesListModal,
|
||
shareToDelete,
|
||
showShareDeleteModal,
|
||
confirmDeleteShare,
|
||
cancelDeleteShare,
|
||
speakerMap,
|
||
speakerDisplayMap,
|
||
modalSpeakers,
|
||
regenerateSummaryAfterSpeakerUpdate,
|
||
identifiedSpeakers,
|
||
identifiedSpeakersInOrder,
|
||
hasSpeakerNames,
|
||
openSpeakerModal,
|
||
closeSpeakerModal,
|
||
saveSpeakerNames,
|
||
highlightedTranscript,
|
||
highlightedSpeaker,
|
||
highlightSpeakerInTranscript,
|
||
focusSpeaker,
|
||
blurSpeaker,
|
||
clearSpeakerHighlight,
|
||
currentSpeakerGroupIndex,
|
||
speakerGroups,
|
||
navigateToNextSpeakerGroup,
|
||
navigateToPrevSpeakerGroup,
|
||
speakerSuggestions,
|
||
loadingSuggestions,
|
||
activeSpeakerInput,
|
||
searchSpeakers,
|
||
selectSpeakerSuggestion,
|
||
closeSpeakerSuggestionsOnClick,
|
||
autoIdentifySpeakers,
|
||
isAutoIdentifying,
|
||
formatDuration,
|
||
openShareModal,
|
||
closeShareModal,
|
||
createShare,
|
||
openSharesList,
|
||
closeSharesList,
|
||
updateShare,
|
||
deleteShare,
|
||
copyShareLink,
|
||
startDraftRename,
|
||
saveDraftRename,
|
||
editingDraftId,
|
||
editingDraftName,
|
||
draftNameInput,
|
||
draftRecordings,
|
||
deleteDraft,
|
||
continueDraftRecording,
|
||
loadDrafts,
|
||
|
||
// Recording Size Monitoring
|
||
estimatedFileSize,
|
||
fileSizeWarningShown,
|
||
recordingQuality,
|
||
actualBitrate,
|
||
maxRecordingMB,
|
||
sizeCheckInterval,
|
||
updateFileSizeEstimate,
|
||
startSizeMonitoring,
|
||
stopSizeMonitoring,
|
||
sumupResult,
|
||
lastTimer,
|
||
promptVisible,
|
||
promptList,
|
||
openPromptModal,
|
||
closePromptModal,
|
||
confirmPromptSelection,
|
||
selectedPromptId,
|
||
recommendPromptId,
|
||
recommendPromptName,
|
||
isMarkdown,
|
||
renderedMarkdown,
|
||
recommendPromptReason,
|
||
showSpeaker,
|
||
isEditingTranscription,
|
||
editableText,
|
||
enterEditMode,
|
||
exitEditMode,
|
||
};
|
||
},
|
||
delimiters: ["${", "}"],
|
||
});
|
||
|
||
//--------------------模板暴露与事件出口 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------------------
|
||
});
|