9184 lines
336 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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++, // 本地递增 IDLLM 返回时用它定位这条 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------------------
});