6783 lines
248 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.

// =============================================================================
// 文件来源:从 src/main-old.js 拆分而来Vue 应用主体)
// 实时 ASR 链路拆到 ./asr/realtime-asr-pipeline.js
// 录音详情高级操作(重处理/转写编辑/说话人/状态/标记/轮询)拆到
// ./composables/useRecordingOperations.js。
//
// 与原代码main-old.js相比的【行为差异】需在迁移/回归时特别注意:
//
// A. 说话人/重处理相关状态改为“单一数据源 + 依赖注入”2024 重构修复)
// - 原代码speakerMap / highlightedSpeaker / showSpeakerModal /
// speakerSuggestions / regenerateSummaryAfterSpeakerUpdate / asrReprocessOptions
// 等只在 main.js setup 内部定义一份openSpeakerModal/saveSpeakerNames/
// highlightSpeakerInTranscript 等方法直接闭包读写同一份 ref。
// - 拆分初版useRecordingOperations 内部又创建了一份同名 ref导致
// main.js 的 processedTranscription / highlightedTranscript / handleClickAway
// 读到的是 main.js 那份(空),而 composable 的方法写的是 composable 那份
// → 出现“名字不显示 / 高亮不渲染 / 点击外部不收起建议”4 个回归。
// - 现行做法(方案 B这些 ref 统一在 main.js setup 顶部定义,作为
// 依赖注入传给 useRecordingOperationscomposable 内部不再创建同名 ref
// 全部读写注入的那份。由此 processedTranscriptionmain.js
// openSpeakerModalcomposable操作同一份 speakerMap4 个回归全部修复。
// - 副作用regenerateSummaryAfterSpeakerUpdate 默认值由 true 改为 false
// (对齐原 composable 默认保持修复前实际生效行为asrReprocessOptions
// 由 reactive 改为 ref对齐 composable 类型模板自动解包v-model 正常)。
//
// B. resumeRecording 不再设置 resetTranscriptionFlag时序变化结果等价
// - 原代码resume 时设 resetTranscriptionFlag=true等下一条 ws 消息进来
// 时 getJsonMessage 再 clearRealtimePipeline()。
// - 新代码resume 开头直接 resetRealtimeTranscript(),立刻清。
// - 结果等价,时序不同(更干净)。
//
// C. continueDraftRecording 现在会调 resetRealtimeTranscript更彻底
// - 原代码:加载草稿只重置 segmentRenderStartIndex=0。
// - 新代码:额外清空 pendingSentenceQueue 并 startNewEpoch。
// - 基本是改进,但若依赖 pending 队列残留需注意。
//
// D. ASR 链路行为差异见 ./asr/realtime-asr-pipeline.js 文件头注释
// ws 断连处理、stopRecording 关 Recorder、correct 新增 scene、
// wsconnecter getScene、appendInt16 性能、dispose 屏障)。
//
// E. 隐式契约:以下 23 个 refspeakerMap、highlightedSpeaker、showSpeakerModal、
// showResetModal、showReprocessModal、showTextEditorModal、showAsrEditorModal、
// editingTranscriptionContent、editingSegments、availableSpeakers、
// speakerDisplayMap、modalSpeakers、regenerateSummaryAfterSpeakerUpdate、
// speakerSuggestions、loadingSuggestions、activeSpeakerInput、isAutoIdentifying、
// currentSpeakerGroupIndex、speakerGroups、recordingToReset、reprocessType、
// reprocessRecording、asrReprocessOptions必须在 useRecordingOperations(...)
// 调用之前声明(否则 TDZ。当前都满足refs 在 ~237-300调用在 ~1517
// 未来重构请勿把这些 ref 挪到调用点之后。
// =============================================================================
import * as Vue from "vue";
import "./csrf-refresh.js";
import "./i18n.js";
import { RealtimeAsrPipeline } from './asr/realtime-asr-pipeline.js';
import { EasyMDE, marked, assertIndexVendorGlobals } from './vendor-globals.js';
import { useRecordingOperations } from './composables/useRecordingOperations.js';
import * as recordingState from './composables/useRecordingState.js';
import { useSidebar } from './composables/useSidebar.js';
const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
assertIndexVendorGlobals();
// let isfilemode = false;
// Wait for the DOM to be fully loaded before mounting the Vue app
//------------------- 页面启动与 Vue 初始化 start------------------
document.addEventListener("DOMContentLoaded", async () => {
// Initialize i18n before creating Vue app
if (window.i18n) {
const appElement = document.getElementById("app");
const userLang =
appElement?.dataset.userLanguage ||
localStorage.getItem("preferredLanguage") ||
"en";
await window.i18n.init(userLang);
console.log("i18n initialized with language:", userLang);
}
// CSRF Token Integration with Vue.js
const csrfToken = ref(
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
);
// Register Service Worker only when the browser accepts the current TLS context.
if ("serviceWorker" in navigator && window.isSecureContext && window.location.hostname !== "localhost") {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/tool/speakr/static/sw.js")
.then((registration) => {
console.log(
"ServiceWorker registration successful with scope: ",
registration.scope
);
})
.catch((error) => {
console.log("ServiceWorker registration failed: ", error);
});
});
}
// Create a safe t function that's always available
const safeT = (key, params = {}) => {
if (!window.i18n || !window.i18n.t) {
return key; // Return key as fallback without warning during initial render
}
return window.i18n.t(key, params);
};
let realtimeAsrPipeline = null;
let systemRealtimeAsrPipeline = null;
//--------------------页面启动与 Vue 初始化 end------------------
//------------------- Vue 应用主体定义 start------------------
const app = createApp({
setup() {
let isRec = false;
//------------------- 响应式状态与实时转写基础状态 start------------------
// --- Core State ---
// 新增说话人占位
let lastSpeaker = "";
let configwords = "";
const currentView = ref("upload"); // 'upload' or 'recording'
const dragover = ref(false);
const recordings = ref([]);
const selectedRecording = ref(null);
const selectedTab = ref("summary"); // 'summary' or 'notes'
const searchQuery = ref("");
const isLoadingRecordings = ref(true);
const globalError = ref(null);
// Advanced filter state - 已移至 useSidebar
// --- Pagination State ---
const currentPage = ref(1);
const perPage = ref(25);
const totalRecordings = ref(0);
const totalPages = ref(0);
const hasNextPage = ref(false);
const hasPrevPage = ref(false);
const isLoadingMore = ref(false);
// searchDebounceTimer 已移至 useSidebar
const searchDebounceTimer = ref(null);
// --- Enhanced Search & Organization State ---
// sortBy 已移至 useSidebar
const selectedTagFilter = ref(null); // For filtering by clicked tag
// --- UI State ---
const browser = ref("unknown");
// isSidebarCollapsed 已移至 useSidebar
const searchTipsExpanded = ref(false);
const isUserMenuOpen = ref(false);
const isDarkMode = ref(false);
const currentColorScheme = ref("blue");
const showColorSchemeModal = ref(false);
const windowWidth = ref(window.innerWidth);
const mobileTab = ref("transcript");
const isMetadataExpanded = ref(false);
// --- i18n State ---
const currentLanguage = ref("en");
const currentLanguageName = ref("English");
const availableLanguages = ref([]);
const showLanguageMenu = ref(false);
// --- Upload State ---
const uploadQueue = ref([]);
const currentlyProcessingFile = ref(null);
const processingProgress = ref(0);
const processingMessage = ref("");
const isProcessingActive = ref(false);
const pollInterval = ref(null);
const progressPopupMinimized = ref(false);
const progressPopupClosed = ref(false);
const maxFileSizeMB = ref(250); // Default value, will be updated from API
const chunkingEnabled = ref(true); // Default value, will be updated from API
const chunkingMode = ref("size"); // 'size' or 'duration', will be updated from API
const chunkingLimit = ref(20); // Value in MB or seconds, will be updated from API
const chunkingLimitDisplay = ref("20MB"); // Human readable display, will be updated from API
const recordingDisclaimer = ref(""); // Recording disclaimer text from admin settings
// --- Audio Recording State ---
// editingDraftId, editingDraftName 已移至 useSidebar
const draftNameInput = ref(null);
// ============== 从 useRecordingState 导入录音/ASR 状态 ==============
const {
isRecording,
isStoppingRecording,
isPausingRecording,
is_final,
mediaRecorder,
audioChunks,
audioBlobURL,
recordingTime,
recordingInterval,
canRecordAudio,
canRecordSystemAudio,
systemAudioSupported,
systemAudioError,
recordingNotes,
showSystemAudioHelp,
asrLanguage,
asrMinSpeakers,
asrMaxSpeakers,
audioContext,
analyser,
micAnalyser,
systemAnalyser,
visualizer,
micVisualizer,
systemVisualizer,
animationFrameId,
recordingMode,
activeStreams,
estimatedFileSize,
fileSizeWarningShown,
recordingQuality,
actualBitrate,
maxRecordingMB,
sizeCheckInterval,
isPaused,
currentDraftId,
pausedDuration,
pausedTranscription,
draftRecordings,
draftsExpanded,
asrResult,
asrResultOnline,
asrResultOffline,
showRecordingDisclaimerModal,
pendingRecordingMode,
cleanupRecording,
resetASRState,
} = recordingState;
// Advanced Options for ASR
const showAdvancedOptions = ref(false);
const uploadLanguage = ref(""); // Empty string for auto-detect
const uploadMinSpeakers = ref(""); // Empty string for auto-detect
const uploadMaxSpeakers = ref(""); // Empty string for auto-detect
// Tag Selection
const availableTags = ref([]);
const selectedTagIds = ref([]); // Changed to array for multiple selection
const uploadTagSearchFilter = ref(""); // For filtering tags in upload view
const selectedTags = computed(() => {
return selectedTagIds.value
.map((tagId) => availableTags.value.find((tag) => tag.id == tagId))
.filter(Boolean); // Filter out undefined tags
});
// 自动判断是否为 Markdown
const isMarkdown = computed(() => {
const v = analysisResult.value;
return (
v.includes("#") ||
v.includes("**") ||
v.includes("```") ||
v.includes("* ") ||
v.includes("- ")
);
});
// 解析 Markdown → HTML
const renderedMarkdown = Vue.computed(() => {
return window.marked.parse(analysisResult.value || "", {
breaks: true,
});
});
// Computed property for filtered available tags in upload view
const filteredAvailableTagsForUpload = computed(() => {
const availableForSelection = availableTags.value.filter(
(tag) => !selectedTagIds.value.includes(tag.id)
);
if (!uploadTagSearchFilter.value) return availableForSelection;
const filter = uploadTagSearchFilter.value.toLowerCase();
return availableForSelection.filter((tag) =>
tag.name.toLowerCase().includes(filter)
);
});
// --- Modal State ---
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const showEditTagsModal = ref(false);
const selectedNewTagId = ref("");
const tagSearchFilter = ref(""); // For filtering tags in the modal
const showReprocessModal = ref(false);
const showResetModal = ref(false);
const showSpeakerModal = ref(false);
const showShareModal = ref(false);
const showSharesListModal = ref(false);
const showTextEditorModal = ref(false);
const showAsrEditorModal = ref(false);
const editingRecording = ref(null);
const editingTranscriptionContent = ref("");
const editingSegments = ref([]);
const availableSpeakers = ref([]);
const recordingToShare = ref(null);
const shareOptions = reactive({
share_summary: true,
share_notes: true,
});
const generatedShareLink = ref("");
const existingShareDetected = ref(false);
const userShares = ref([]);
const isLoadingShares = ref(false);
const shareToDelete = ref(null);
const showShareDeleteModal = ref(false);
const recordingToDelete = ref(null);
const recordingToReset = ref(null);
const reprocessType = ref(null); // 'transcription' or 'summary'
const reprocessRecording = ref(null);
const isAutoIdentifying = ref(false);
// 【行为差异 A】原 main-old.js 用 reactive({language:"", min_speakers:null,
// max_speakers:null});这里改为 ref 并对齐 composable 默认zh/1/10
// 以便作为依赖注入给 useRecordingOperationscomposable 内部按 ref 访问
// .value。模板 v-model="asrReprocessOptions.language" 在 ref 下自动解包,
// 行为正常。
const asrReprocessOptions = ref({
language: "zh",
min_speakers: 1,
max_speakers: 10,
});
const speakerMap = ref({});
// 【行为差异 A】原 main.js 顶部默认是 true但模板实际用的是
// recordingOps.regenerateSummaryAfterSpeakerUpdatecomposable 那份,默认
// false。修复后统一为注入的这份默认值对齐为 false保持修复前实际
// 生效行为。
const regenerateSummaryAfterSpeakerUpdate = ref(false);
const speakerSuggestions = ref({});
const loadingSuggestions = ref({});
const activeSpeakerInput = ref(null);
// 以下 4 个 ref 原本只在 composable 内部存在,方案 B 改为 main.js 定义
// 并注入,确保 main.js 的 computed/handler 与 composable 方法同源。
const speakerDisplayMap = ref({});
const modalSpeakers = ref([]);
const currentSpeakerGroupIndex = ref(-1);
const speakerGroups = ref([]);
// --- Inline Editing State ---
const editingParticipants = ref(false);
const editingMeetingDate = ref(false);
const editingSummary = ref(false);
const editingNotes = ref(false);
const tempNotesContent = ref("");
const tempSummaryContent = ref("");
const autoSaveTimer = ref(null);
const autoSaveDelay = 2000; // 2 seconds debounce
// --- Markdown Editor State ---
const notesMarkdownEditor = ref(null);
const markdownEditorInstance = ref(null);
const summaryMarkdownEditor = ref(null);
const summaryMarkdownEditorInstance = ref(null);
const recordingNotesEditor = ref(null);
const recordingMarkdownEditorInstance = ref(null);
// --- Transcription State ---
const transcriptionViewMode = ref("simple"); // 'simple' or 'bubble'
const legendExpanded = ref(false);
const highlightedSpeaker = ref(null);
// --- Chat State ---
const showChat = ref(false);
const isChatMaximized = ref(false);
const chatMessages = ref([]);
const chatInput = ref("");
const isChatLoading = ref(false);
const chatMessagesRef = ref(null);
// --- Audio Player State ---
const playerVolume = ref(1.0);
// --- Column Resizing State ---
const leftColumnWidth = ref(60); // 60% for left column (transcript)
const rightColumnWidth = ref(40); // 40% for right column (summary/chat)
const isResizing = ref(false);
// --- App Configuration ---
const useAsrEndpoint = ref(false);
const currentUserName = ref("");
// asrResult, asrResultOnline, asrResultOffline 从 useRecordingState 导入
const asrResultTextarea = ref("");
const asrResultTextareaOnline = ref("");
const sumupResultTextarea = ref("");
const baseInfo = ref({ name: "领导发言稿", fontSize: 40 });
const isModalOpen = ref(false);
const asrTextarea = ref(null);
const analysisResult = ref("");
const isDialogVisible = ref(false);
const isyjDialogVisible = ref(false);
const lastTimer = ref(0);
const systemLastTimer = ref(0);
const urlmsg = ref("获取链接中...");
const urlinfo = ref("");
const zjloding = ref(false);
const showSpeaker = ref(true);
const selectedScene = ref((localStorage.getItem("selectedScene") || "cg").toLowerCase() === "yl" ? "yl" : "cg");
// --- 转录文本编辑状态 ---
const isEditingTranscription = ref(false);
const editableText = ref("");
const systemAsrResult = ref("");
const systemAsrResultOnline = ref("");
const systemAsrResultOffline = ref("");
const systemPausedTranscription = ref("");
const systemEditableText = ref("");
const systemAsrResultTextarea = ref(null);
const systemAsrResultTextareaOnline = ref(null);
const dualTranscriptContainer = ref(null);
const dualTranscriptTopPercent = ref(66.67);
const isResizingDualTranscript = ref(false);
let dualTranscriptResizeElement = null;
const fullRealtimeTranscription = computed(() => {
return (
(asrResult.value || "") +
(asrResultOffline.value || "") +
(asrResultOnline.value || "")
);
});
const fullSystemRealtimeTranscription = computed(() => {
return (
(systemAsrResult.value || "") +
(systemAsrResultOffline.value || "") +
(systemAsrResultOnline.value || "")
);
});
const isDualRealtimeTranscription = computed(() => recordingMode.value === "both");
const combinedRealtimeTranscription = computed(() => {
if (!isDualRealtimeTranscription.value) {
return fullRealtimeTranscription.value;
}
const micText = fullRealtimeTranscription.value || "";
const systemText = fullSystemRealtimeTranscription.value || "";
const parts = [];
if (micText) {
parts.push("[MIC]\n" + micText);
}
if (systemText) {
parts.push("[SYSTEM]\n" + systemText);
}
return parts.join("\n\n");
});
function updateDualTranscriptSplit(
clientY,
container = dualTranscriptResizeElement || dualTranscriptContainer.value
) {
if (!container) return;
const rect = container.getBoundingClientRect();
if (!rect.height) return;
const nextPercent = ((clientY - rect.top) / rect.height) * 100;
dualTranscriptTopPercent.value = Math.min(80, Math.max(35, nextPercent));
}
function stopDualTranscriptResize() {
if (!isResizingDualTranscript.value) return;
isResizingDualTranscript.value = false;
dualTranscriptResizeElement = null;
window.removeEventListener("mousemove", onDualTranscriptResizeMove);
window.removeEventListener("mouseup", stopDualTranscriptResize);
}
function onDualTranscriptResizeMove(event) {
if (!isResizingDualTranscript.value) return;
updateDualTranscriptSplit(event.clientY);
}
function startDualTranscriptResize(event) {
if (!isDualRealtimeTranscription.value) return;
event.preventDefault();
dualTranscriptResizeElement =
event.currentTarget?.closest?.(".dual-transcript-stack") ||
event.currentTarget?.parentElement ||
dualTranscriptContainer.value;
isResizingDualTranscript.value = true;
updateDualTranscriptSplit(event.clientY, dualTranscriptResizeElement);
window.addEventListener("mousemove", onDualTranscriptResizeMove);
window.addEventListener("mouseup", stopDualTranscriptResize);
}
function clearSystemRealtimeTranscript() {
systemAsrResult.value = "";
systemAsrResultOnline.value = "";
systemAsrResultOffline.value = "";
systemPausedTranscription.value = "";
systemEditableText.value = "";
systemLastTimer.value = 0;
}
function getRealtimeTranscriptionForSave() {
return combinedRealtimeTranscription.value || "";
}
function getPrimaryRealtimeSourceKind() {
if (recordingMode.value === "system") {
return "system";
}
return "mic";
}
function syncRealtimeActiveState() {
isRec = !!(
realtimeAsrPipeline?.isActive() ||
systemRealtimeAsrPipeline?.isActive()
);
}
function getRealtimePipelines() {
return [realtimeAsrPipeline, systemRealtimeAsrPipeline].filter(Boolean);
}
function createPipelineForSource(sourceKind, callbacks) {
return new RealtimeAsrPipeline({
getMicStream: () => recordingState.micStream,
getSystemStream: () => recordingState.systemStream,
getPausedTranscription: callbacks.getPausedTranscription,
setPausedTranscription: callbacks.setPausedTranscription,
getShowSpeaker: () => showSpeaker.value,
getConfigWords: () => configwords,
getScene: () => selectedScene.value,
getLastTimer: () =>
callbacks.getLastTimer ? callbacks.getLastTimer() : lastTimer.value || 0,
setLastTimer: (value) => {
if (callbacks.setLastTimer) {
callbacks.setLastTimer(value);
} else {
lastTimer.value = value;
}
},
onMainTextChange: callbacks.onMainTextChange,
onPreviewTextChange: callbacks.onPreviewTextChange,
onOfflineTextChange: callbacks.onOfflineTextChange,
onActiveChange: syncRealtimeActiveState,
onError: (error) => {
console.error(`Realtime ASR pipeline (${sourceKind}) error:`, error);
},
sourceKind,
});
}
function startNewRealtimeEpoch(options = {}) {
getRealtimePipelines().forEach((pipeline) => pipeline.startNewEpoch(options));
}
function resetRealtimeTranscript(options = {}) {
getRealtimePipelines().forEach((pipeline) => pipeline.resetTranscript(options));
if (options.clearTimestampBarrier || !systemRealtimeAsrPipeline) {
clearSystemRealtimeTranscript();
}
}
function prepareRealtimeForResume() {
getRealtimePipelines().forEach((pipeline) => pipeline.prepareForResume());
}
function disposeRealtimeAsrPipeline() {
getRealtimePipelines().forEach((pipeline) => pipeline.dispose());
realtimeAsrPipeline = null;
systemRealtimeAsrPipeline = null;
isRec = false;
}
function createRealtimeAsrPipeline() {
disposeRealtimeAsrPipeline();
realtimeAsrPipeline = createPipelineForSource(getPrimaryRealtimeSourceKind(), {
getPausedTranscription: () => pausedTranscription.value || "",
setPausedTranscription: (text) => {
pausedTranscription.value = text || "";
},
onMainTextChange: (text) => {
asrResult.value = text || "";
},
onPreviewTextChange: (text) => {
asrResultOnline.value = text || "";
},
onOfflineTextChange: (text) => {
asrResultOffline.value = text || "";
},
});
if (recordingMode.value === "both") {
systemRealtimeAsrPipeline = createPipelineForSource("system", {
getPausedTranscription: () => systemPausedTranscription.value || "",
setPausedTranscription: (text) => {
systemPausedTranscription.value = text || "";
},
getLastTimer: () => systemLastTimer.value || 0,
setLastTimer: (value) => {
systemLastTimer.value = value;
},
onMainTextChange: (text) => {
systemAsrResult.value = text || "";
},
onPreviewTextChange: (text) => {
systemAsrResultOnline.value = text || "";
},
onOfflineTextChange: (text) => {
systemAsrResultOffline.value = text || "";
},
});
} else {
clearSystemRealtimeTranscript();
}
return realtimeAsrPipeline;
}
function ensureRealtimeAsrPipeline() {
if (!realtimeAsrPipeline || (recordingMode.value === "both" && !systemRealtimeAsrPipeline)) {
return createRealtimeAsrPipeline();
}
if (recordingMode.value !== "both" && systemRealtimeAsrPipeline) {
return createRealtimeAsrPipeline();
}
return realtimeAsrPipeline;
}
function startRealtimeAsrPipelines() {
ensureRealtimeAsrPipeline();
const started = getRealtimePipelines()
.map((pipeline) => pipeline.start())
.some((result) => result === 1);
syncRealtimeActiveState();
return started ? 1 : 0;
}
async function finishRealtimeAsrPipelines(timeoutMs = 20000) {
const pipelines = getRealtimePipelines();
await Promise.allSettled(pipelines.map((pipeline) => pipeline.finish(timeoutMs)));
syncRealtimeActiveState();
}
function stopRealtimeAsrPipelines() {
getRealtimePipelines().forEach((pipeline) => pipeline.stop());
syncRealtimeActiveState();
}
function normalizeScene(scene) {
return String(scene || "cg").trim().toLowerCase() === "yl" ? "yl" : "cg";
}
function setSelectedScene(scene) {
const nextScene = normalizeScene(scene);
if (isRecording.value || isPaused.value || isStoppingRecording.value || isRec === true) {
showToast("录音中无法切换场景", "error");
return;
}
selectedScene.value = nextScene;
localStorage.setItem("selectedScene", nextScene);
}
async function waitForRealtimeCorrections(timeoutMs = 8000) {
const pipelines = getRealtimePipelines();
if (pipelines.length === 0) {
return 0;
}
const count = pipelines.reduce(
(total, pipeline) => total + pipeline.getPendingCorrectionCount(),
0
);
if (count > 0) {
console.log('Waiting for ' + count + ' ASR corrections...');
}
const results = await Promise.all(
pipelines.map((pipeline) => pipeline.waitForCorrections(timeoutMs))
);
const remaining = results.reduce((total, value) => total + value, 0);
if (remaining > 0) {
console.warn('Timed out with ' + remaining + ' ASR corrections still pending');
}
return remaining;
}
// 暂停录音时调用:把所有未矫正的预览文本立即拿去矫正并加入 segmentList
async function finalizeRemainingSentences(timeoutMs = 8000) {
await Promise.all(
getRealtimePipelines().map((pipeline) =>
pipeline.finalizeRemainingSentences(timeoutMs)
)
);
}
//--------------------响应式状态与实时转写基础状态 end------------------
//------------------- 计算属性与派生展示数据 start------------------
// --- Computed Properties ---
const isMobileScreen = computed(() => {
return windowWidth.value < 1024;
});
// datePresetOptions 已移至 useSidebar
const languageOptions = computed(() => {
return [
{ value: "", label: t("form.autoDetect") },
{ value: "en", label: t("languages.en") },
{ value: "es", label: t("languages.es") },
{ value: "fr", label: t("languages.fr") },
{ value: "de", label: t("languages.de") },
{ value: "it", label: t("languages.it") },
{ value: "pt", label: t("languages.pt") },
{ value: "nl", label: t("languages.nl") },
{ value: "ru", label: t("languages.ru") },
{ value: "zh", label: t("languages.zh") },
{ value: "ja", label: t("languages.ja") },
{ value: "ko", label: t("languages.ko") },
];
});
const filteredRecordings = computed(() => {
return recordings.value;
});
// highlightedTranscript 的高亮/转义/着色实现统一由 useRecordingOperations
// 提供recordingOps.highlightedTranscript模板暴露的也是那份
// 此处不再保留重复实现,避免双份 computed 读到不同 speakerMap 导致显示不一致。
const activeRecordingMetadata = computed(() => {
if (!selectedRecording.value) return [];
const recording = selectedRecording.value;
const metadata = [];
if (recording.created_at) {
metadata.push({
icon: "fas fa-history",
text: formatDisplayDate(recording.created_at),
});
}
if (recording.file_size) {
metadata.push({
icon: "fas fa-file-audio",
text: formatFileSize(recording.file_size),
});
}
if (recording.duration) {
metadata.push({
icon: "fas fa-clock",
text: formatDuration(recording.duration),
});
}
if (recording.original_filename) {
const maxLength = 30;
const truncated =
recording.original_filename.length > maxLength
? recording.original_filename.substring(0, maxLength) + "..."
: recording.original_filename;
metadata.push({
icon: "fas fa-file",
text: truncated,
fullText: recording.original_filename,
});
}
// Add tags to metadata
if (recording.tags && recording.tags.length > 0) {
metadata.push({
icon: "fas fa-tags",
text: "", // Empty text since we'll render tags specially
tags: recording.tags, // Pass the tags array
isTagItem: true, // Flag to identify this as a tag item
});
}
return metadata;
});
// groupedRecordings 已移至 useSidebar
const totalInQueue = computed(() => uploadQueue.value.length);
const completedInQueue = computed(
() =>
uploadQueue.value.filter(
(item) => item.status === "completed" || item.status === "failed"
).length
);
const finishedFilesInQueue = computed(() =>
uploadQueue.value.filter((item) =>
["completed", "failed"].includes(item.status)
)
);
const showRecordingControls = computed(() => {
if (currentView.value !== "recording") {
return false;
}
const hasCompletedRecording =
!isRecording.value && !isPaused.value && !!audioBlobURL.value;
if (hasCompletedRecording) {
return false;
}
if (isRecording.value || isPaused.value || isStoppingRecording.value) {
return true;
}
return (
!audioBlobURL.value &&
(activeStreams.value.length > 0 ||
recordingTime.value > 0 ||
!!currentDraftId.value)
);
});
const clearCompletedUploads = () => {
uploadQueue.value = uploadQueue.value.filter(
(item) => !["completed", "failed"].includes(item.status)
);
};
const identifiedSpeakers = computed(() => {
// Ensure we have a valid recording and transcription
if (!selectedRecording.value?.transcription) {
return [];
}
const transcription = selectedRecording.value.transcription;
let transcriptionData;
try {
transcriptionData = JSON.parse(transcription);
} catch (e) {
transcriptionData = null;
}
// Updated to handle new simplified JSON format (array of segments)
if (transcriptionData && Array.isArray(transcriptionData)) {
// JSON format - extract speakers in order of appearance
const speakersInOrder = [];
const seenSpeakers = new Set();
transcriptionData.forEach((segment) => {
if (
segment.speaker &&
String(segment.speaker).trim() &&
!seenSpeakers.has(segment.speaker)
) {
seenSpeakers.add(segment.speaker);
speakersInOrder.push(segment.speaker);
}
});
return speakersInOrder; // Keep order of appearance, don't sort
} else if (typeof transcription === "string") {
// Plain text format - find speakers in order of appearance
const speakerRegex = /\[([^\]]+)\]:/g;
const speakersInOrder = [];
const seenSpeakers = new Set();
let match;
while ((match = speakerRegex.exec(transcription)) !== null) {
const speaker = match[1].trim();
if (speaker && !seenSpeakers.has(speaker)) {
seenSpeakers.add(speaker);
speakersInOrder.push(speaker);
}
}
return speakersInOrder; // Keep order of appearance, don't sort
}
return [];
});
// identifiedSpeakersInOrder is now just an alias since identifiedSpeakers already preserves order
const identifiedSpeakersInOrder = computed(() => {
return identifiedSpeakers.value;
});
// hasSpeakerNames 统一由 useRecordingOperations 提供recordingOps.hasSpeakerNames
// 它读的 speakerMap 与本文件注入的是同一份 ref避免双份 computed 结果不一致。
const processedTranscription = computed(() => {
if (!selectedRecording.value?.transcription) {
return {
hasDialogue: false,
content: "",
speakers: [],
simpleSegments: [],
bubbleRows: [],
};
}
const transcription = selectedRecording.value.transcription;
let transcriptionData;
try {
transcriptionData = JSON.parse(transcription);
} catch (e) {
transcriptionData = null;
}
// Handle new simplified JSON format (array of segments)
if (transcriptionData && Array.isArray(transcriptionData)) {
const wasDiarized = transcriptionData.some(
(segment) => segment.speaker
);
if (!wasDiarized) {
const segments = transcriptionData.map((segment) => ({
sentence: segment.sentence,
startTime: segment.start_time,
}));
return {
hasDialogue: false,
isJson: true,
content: segments.map((s) => s.sentence).join("\n"),
simpleSegments: segments,
speakers: [],
bubbleRows: [],
};
}
// Extract unique speakers
const speakers = [
...new Set(
transcriptionData
.map((segment) => segment.speaker)
.filter(Boolean)
),
];
const speakerColors = {};
speakers.forEach((speaker, index) => {
speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
});
const simpleSegments = transcriptionData.map((segment) => ({
speakerId: segment.speaker,
speaker: speakerMap.value[segment.speaker]?.name || segment.speaker,
sentence: segment.sentence,
startTime: segment.start_time || segment.startTime,
endTime: segment.end_time || segment.endTime,
color: speakerColors[segment.speaker] || "speaker-color-1",
}));
const processedSimpleSegments = [];
let lastSpeakerId = null;
simpleSegments.forEach((segment) => {
processedSimpleSegments.push({
...segment,
showSpeaker: segment.speakerId !== lastSpeakerId,
});
lastSpeakerId = segment.speakerId;
});
const bubbleRows = [];
let lastBubbleSpeakerId = null;
simpleSegments.forEach((segment) => {
if (
bubbleRows.length === 0 ||
segment.speakerId !== lastBubbleSpeakerId
) {
bubbleRows.push({
speaker: segment.speaker,
color: segment.color,
isMe:
segment.speaker &&
typeof segment.speaker === "string" &&
segment.speaker.toLowerCase().includes("me"),
bubbles: [],
});
lastBubbleSpeakerId = segment.speakerId;
}
bubbleRows[bubbleRows.length - 1].bubbles.push({
sentence: segment.sentence,
startTime: segment.startTime || segment.start_time,
color: segment.color,
});
});
return {
hasDialogue: true,
isJson: true,
segments: simpleSegments,
simpleSegments: processedSimpleSegments,
bubbleRows: bubbleRows,
speakers: speakers.map((speaker) => ({
name: speakerMap.value[speaker]?.name || speaker,
color: speakerColors[speaker],
})),
};
} else {
// Fallback for plain text transcription
const speakerRegex = /\[([^\]]+)\]:\s*/g;
const hasDialogue = speakerRegex.test(transcription);
if (!hasDialogue) {
return {
hasDialogue: false,
isJson: false,
content: transcription,
speakers: [],
simpleSegments: [],
bubbleRows: [],
};
}
speakerRegex.lastIndex = 0;
const speakers = new Set();
let match;
while ((match = speakerRegex.exec(transcription)) !== null) {
speakers.add(match[1]);
}
const speakerList = Array.from(speakers);
const speakerColors = {};
speakerList.forEach((speaker, index) => {
speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
});
const segments = [];
const lines = transcription.split("\n");
let currentSpeakerId = null;
let currentText = "";
for (const line of lines) {
const speakerMatch = line.match(/^\[([^\]]+)\]:\s*(.*)$/);
if (speakerMatch) {
if (currentSpeakerId && currentText.trim()) {
segments.push({
speakerId: currentSpeakerId,
speaker:
speakerMap.value[currentSpeakerId]?.name ||
currentSpeakerId,
sentence: currentText.trim(),
color: speakerColors[currentSpeakerId] || "speaker-color-1",
});
}
currentSpeakerId = speakerMatch[1];
currentText = speakerMatch[2];
} else if (currentSpeakerId && line.trim()) {
currentText += " " + line.trim();
} else if (!currentSpeakerId && line.trim()) {
segments.push({
speakerId: null,
speaker: null,
sentence: line.trim(),
color: "speaker-color-1",
});
}
}
if (currentSpeakerId && currentText.trim()) {
segments.push({
speakerId: currentSpeakerId,
speaker:
speakerMap.value[currentSpeakerId]?.name || currentSpeakerId,
sentence: currentText.trim(),
color: speakerColors[currentSpeakerId] || "speaker-color-1",
});
}
const simpleSegments = [];
let lastSpeakerId = null;
segments.forEach((segment) => {
simpleSegments.push({
...segment,
showSpeaker: segment.speakerId !== lastSpeakerId,
sentence: segment.sentence || segment.text,
});
lastSpeakerId = segment.speakerId;
});
const bubbleRows = [];
let currentRow = null;
segments.forEach((segment) => {
if (!currentRow || currentRow.speakerId !== segment.speakerId) {
if (currentRow) bubbleRows.push(currentRow);
currentRow = {
speakerId: segment.speakerId,
speaker: segment.speaker,
color: segment.color,
bubbles: [],
isMe:
segment.speaker &&
segment.speaker.toLowerCase().includes("me"),
};
}
currentRow.bubbles.push({
sentence: segment.sentence,
color: segment.color,
});
});
if (currentRow) bubbleRows.push(currentRow);
return {
hasDialogue: true,
isJson: false,
segments: segments,
simpleSegments: simpleSegments,
bubbleRows: bubbleRows,
speakers: speakerList.map((speaker) => ({
name: speakerMap.value[speaker]?.name || speaker,
color: speakerColors[speaker] || "speaker-color-1",
})),
};
}
});
//--------------------计算属性与派生展示数据 end------------------
//------------------- 主题与配色配置 start------------------
// --- Color Scheme Management ---
const colorSchemes = {
light: [
{
id: "blue",
name: "Ocean Blue",
description: "Classic blue theme with professional appeal",
class: "",
},
{
id: "emerald",
name: "Forest Emerald",
description: "Fresh green theme for a natural feel",
class: "theme-light-emerald",
},
{
id: "purple",
name: "Royal Purple",
description: "Elegant purple theme with sophistication",
class: "theme-light-purple",
},
{
id: "rose",
name: "Sunset Rose",
description: "Warm pink theme with gentle energy",
class: "theme-light-rose",
},
{
id: "amber",
name: "Golden Amber",
description: "Warm yellow theme for brightness",
class: "theme-light-amber",
},
{
id: "teal",
name: "Ocean Teal",
description: "Cool teal theme for tranquility",
class: "theme-light-teal",
},
],
dark: [
{
id: "blue",
name: "Midnight Blue",
description: "Deep blue theme for focused work",
class: "",
},
{
id: "emerald",
name: "Dark Forest",
description: "Rich green theme for comfortable viewing",
class: "theme-dark-emerald",
},
{
id: "purple",
name: "Deep Purple",
description: "Mysterious purple theme for creativity",
class: "theme-dark-purple",
},
{
id: "rose",
name: "Dark Rose",
description: "Muted pink theme with subtle warmth",
class: "theme-dark-rose",
},
{
id: "amber",
name: "Dark Amber",
description: "Warm brown theme for cozy sessions",
class: "theme-dark-amber",
},
{
id: "teal",
name: "Deep Teal",
description: "Dark teal theme for calm focus",
class: "theme-dark-teal",
},
],
};
//--------------------主题与配色配置 end------------------
//------------------- 通用工具方法与格式化逻辑 start------------------
// --- Utility Methods ---
const openmodel = async (e) => {
urlmsg.value = "获取链接中...";
isModalOpen.value = true;
let urls = `/tool/speakr/api/external/summarize_text`;
const response = await fetch(urls, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
input_str: asrResult.value,
prompt_id: e ? e.id : "",
}),
});
const data = await response.json();
if (response) {
urlinfo.value = data.string;
urlmsg.value = "点击打开投屏页面";
console.log(data.value, "urlinfo.value", response, data);
}
if (!response.ok) {
throw new Error(data.error || "Failed to reset status");
}
};
const openzjmodel = async () => {
zjloding.value = true;
let urls = `/tool/speakr/api/external/submit`;
try {
const response = await fetch(urls, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
input_str: analysisResult.value,
}),
});
const data = await response.json();
if (response) {
window.open(data.string, "_blank");
zjloding.value = false;
}
if (!response.ok) {
zjloding.value = false;
throw new Error(data.error || "Failed to reset status");
}
} catch (error) {
console.error("接口请求失败:", error);
zjloding.value = false;
}
};
const closeModal = () => {
isModalOpen.value = false;
};
const setGlobalError = (message, duration = 7000) => {
globalError.value = message;
if (duration > 0) {
setTimeout(() => {
if (globalError.value === message) globalError.value = null;
}, duration);
}
};
const formatFileSize = (bytes) => {
if (bytes == null || bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes < 0) bytes = 0;
const i =
bytes === 0
? 0
: Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
const size =
i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return size + " " + sizes[i];
};
const formatDisplayDate = (dateString) => {
if (!dateString) return "";
try {
// Try to parse the date string directly first (it might already be formatted)
let date = new Date(dateString);
// If that fails or results in invalid date, try different approaches
if (isNaN(date.getTime())) {
// Try appending time if it looks like a date-only string (YYYY-MM-DD format)
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
date = new Date(dateString + "T00:00:00");
} else {
// If it's already a formatted string, just return it
return dateString;
}
}
// If we still have an invalid date, return the original string
if (isNaN(date.getTime())) {
return dateString;
}
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
});
} catch (e) {
console.error("Error formatting date:", e);
return dateString;
}
};
const formatStatus = (status) => {
if (!status || status === "COMPLETED") return "";
const statusMap = {
PENDING: t("status.queued"),
PROCESSING: t("status.processing"),
TRANSCRIBING: t("status.transcribing"),
SUMMARIZING: t("status.summarizing"),
FAILED: t("status.failed"),
UPLOADING: t("status.uploading"),
};
return (
statusMap[status] ||
status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()
);
};
const getStatusClass = (status) => {
switch (status) {
case "PENDING":
return "status-pending";
case "PROCESSING":
return "status-processing";
case "SUMMARIZING":
return "status-summarizing";
case "COMPLETED":
return "";
case "FAILED":
return "status-failed";
default:
return "status-pending";
}
};
const formatTime = (seconds) => {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
const formatDuration = (totalSeconds) => {
if (totalSeconds == null || totalSeconds < 0) return "N/A";
if (totalSeconds < 1) {
return `${totalSeconds.toFixed(2)} seconds`;
}
totalSeconds = Math.round(totalSeconds);
if (totalSeconds < 60) {
return `${totalSeconds} sec`;
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
let parts = [];
if (hours > 0) {
parts.push(`${hours} hr`);
}
if (minutes > 0) {
parts.push(`${minutes} min`);
}
// Only show seconds if duration is less than an hour
if (hours === 0 && seconds > 0) {
parts.push(`${seconds} sec`);
}
return parts.join(" ");
};
const createLocalizedRecordingFilename = () => {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
return `录音_${timestamp}.webm`;
};
const getDownloadFilenameFromHeaders = (
contentDisposition,
fallbackFilename
) => {
if (!contentDisposition) {
return fallbackFilename;
}
const utf8Match = /filename\*=utf-8''([^;]+)/i.exec(contentDisposition);
if (utf8Match && utf8Match[1]) {
return decodeURIComponent(utf8Match[1]);
}
const regularMatch = /filename="([^"]+)"/i.exec(contentDisposition);
if (regularMatch && regularMatch[1]) {
return regularMatch[1];
}
return fallbackFilename;
};
//--------------------通用工具方法与格式化逻辑 end------------------
//------------------- 录音体积监控逻辑 start------------------
// --- Recording Size Monitoring Functions ---
const updateFileSizeEstimate = () => {
if (!isRecording.value || !actualBitrate.value) return;
// Calculate estimated size based on recording time and bitrate
const recordingTimeSeconds = recordingTime.value;
const estimatedBits = actualBitrate.value * recordingTimeSeconds;
const estimatedBytes = estimatedBits / 8;
estimatedFileSize.value = estimatedBytes;
// Check if we're approaching the size limit
const sizeMB = estimatedBytes / (1024 * 1024);
const warningThresholdMB = maxRecordingMB.value * 0.8; // 80% of max size
if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) {
fileSizeWarningShown.value = true;
showToast(
`Recording size is ${formatFileSize(
estimatedBytes
)}. Consider stopping soon to avoid auto-stop at ${
maxRecordingMB.value
}MB.`,
"fa-exclamation-triangle",
5000
);
}
// Auto-stop if we exceed the maximum size
if (sizeMB > maxRecordingMB.value) {
console.log(
`Auto-stopping recording: size ${formatFileSize(
estimatedBytes
)} exceeds limit of ${maxRecordingMB.value}MB`
);
stopRecording();
showToast(
`Recording automatically stopped at ${formatFileSize(
estimatedBytes
)} to prevent excessive file size.`,
"fa-stop-circle",
7000
);
}
};
const startSizeMonitoring = () => {
if (sizeCheckInterval.value) {
clearInterval(sizeCheckInterval.value);
}
// Reset size monitoring state
estimatedFileSize.value = 0;
fileSizeWarningShown.value = false;
// Start monitoring every 5 seconds
sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 5000);
};
const stopSizeMonitoring = () => {
if (sizeCheckInterval.value) {
clearInterval(sizeCheckInterval.value);
sizeCheckInterval.value = null;
}
};
//--------------------录音体积监控逻辑 end------------------
//------------------- 总结提示词与外部分析流程 start------------------
const promptVisible = ref(false);
const promptList = ref([]);
const recommendPromptId = ref(null);
const recommendPromptName = ref("");
const recommendPromptReason = ref("");
const selectedPromptId = ref(
localStorage.getItem("selected_prompt_id") || null
);
// const selectedPromptId = ref(null)
let modelTypeForP = "";
/** 打开弹框 */
const openPromptModal = (e) => {
modelTypeForP = e;
recommendPromptId.value = null;
recommendPromptName.value = "";
recommendPromptReason.value = "";
promptVisible.value = true;
getPromptList(e);
getRecommend();
};
/** 关闭弹框 */
const closePromptModal = () => {
promptVisible.value = false;
};
/** 获取 Prompt 列表 */
const getPromptList = async (e) => {
try {
const res = await fetch(
`/tool/speakr/api/external/prompts/list`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skip: 0, limit: 999 }),
}
);
const data = await res.json();
promptList.value = data.prompt_list || [];
} catch (e) {
console.error("提示词加载失败", e);
}
};
/** 根据文本获取Prompt推荐 */
const getRecommend = async (e) => {
try {
const res = await fetch(
`/tool/speakr/api/external/recommend_prompts`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input_str: asrResult.value }),
}
);
const data = await res.json();
recommendPromptId.value = data.id;
recommendPromptName.value = data.name;
recommendPromptReason.value = data.reason;
} catch (e) {
console.error("获取推荐失败", e);
}
};
/** 确认选择 */
const confirmPromptSelection = () => {
if (selectedPromptId.value) {
localStorage.setItem("selected_prompt_id", selectedPromptId.value);
}
const selectedPrompt = promptList.value.find(
(i) => i.id == selectedPromptId.value
);
console.log("选中的提示词:", selectedPrompt);
if (modelTypeForP === "zj") {
sumupResult(selectedPrompt);
} else {
openmodel(selectedPrompt);
}
// emit("promptSelected", selectedPrompt)
promptVisible.value = false;
};
const sumupResult = async (e) => {
isDialogVisible.value = true;
// if (!asrResult.value) return;
try {
analysisResult.value = "思考中...";
const response = await fetch(
`/tool/speakr/api/external/summarize_text_stream`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
input_str: asrResult.value,
// input_str: '香港火灾情况更新** 香港特区政府召开新闻发布会通报火灾的搜救与安置最新进展。警方表示已完成5座大厦的搜索其余2座仍在进行中。至当天16时火灾已造成 **156人遇难**目前有约35人参与搜救工作其中5人连续工作。2. **对话内容提及天气** 有用户询问“今天天气怎么样”地点为“安康”表明对话中涉及对当地天气的关注。3. **对话片段含非正式表达** 有用户发言“我是走开始啊不是”,语句不通顺,可能是口语化表达或输入错误,语义不明确。',
prompt_id: e ? e.id : "",
}),
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to create share link");
}
// 重置结果并开始流式处理
analysisResult.value = "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码并处理流数据
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.slice(6));
if (data.content) {
analysisResult.value += data.content;
}
if (data.finished) {
console.log(analysisResult.value, "analysisResult.value");
showToast("总结完成!", "fa-check-circle");
break;
}
} catch (e) {
console.log("解析流数据出错:", e);
}
}
}
}
} catch (error) {
setGlobalError(`Failed to create share link: ${error.message}`);
}
};
//--------------------总结提示词与外部分析流程 end------------------
// 日期分组与排序工具已移至 useSidebar
//------------------- 录音详情高级操作 start------------------
const resetCurrentFileProcessingState = () => {
if (pollInterval.value) clearInterval(pollInterval.value);
pollInterval.value = null;
currentlyProcessingFile.value = null;
processingProgress.value = 0;
processingMessage.value = "";
};
// ================== 使用侧边栏组合式函数 ==================
// useSidebar 调用已移至 loadRecordings 定义之后(避免 TDZ 错误,
// 因为 useSidebar 内部的 loadMoreRecordings 依赖 loadRecordings
// 使用组合式函数管理录音高级操作
// 【行为差异 A / 方案 B】说话人/重处理/编辑相关共享状态统一由 main.js
// 顶部定义并注入 composable建立单一数据源避免 composable 内部再创建
// 一份导致 main.js 的 computed/handler 读到空对象(拆分初版的 4 个回归
// 根因)。注意:以下注入的 ref 必须在此调用之前已声明(见文件头 E
const recordingOps = useRecordingOperations({
recordings,
selectedRecording,
selectedTab,
uploadQueue,
currentlyProcessingFile,
processingProgress,
processingMessage,
progressPopupMinimized,
progressPopupClosed,
identifiedSpeakers,
processedTranscription,
showToast,
setGlobalError,
resetCurrentFileProcessingState,
// 说话人/重处理/编辑相关共享状态(单一数据源,避免 composable 内部
// 再创建一份导致 main.js 的 computed/handler 读到空对象)
speakerMap,
highlightedSpeaker,
showSpeakerModal,
showResetModal,
showReprocessModal,
showTextEditorModal,
showAsrEditorModal,
editingTranscriptionContent,
editingSegments,
availableSpeakers,
speakerDisplayMap,
modalSpeakers,
regenerateSummaryAfterSpeakerUpdate,
speakerSuggestions,
loadingSuggestions,
activeSpeakerInput,
isAutoIdentifying,
currentSpeakerGroupIndex,
speakerGroups,
recordingToReset,
reprocessType,
reprocessRecording,
asrReprocessOptions,
});
// 为了兼容模板中的直接调用,保留这些函数引用
const reprocessTranscription = recordingOps.reprocessTranscription;
const reprocessSummary = recordingOps.reprocessSummary;
const generateSummary = recordingOps.generateSummary;
const resetRecordingStatus = recordingOps.resetRecordingStatus;
const confirmReprocess = recordingOps.confirmReprocess;
const cancelReprocess = recordingOps.cancelReprocess;
const executeReprocess = recordingOps.executeReprocess;
const openTranscriptionEditor = recordingOps.openTranscriptionEditor;
const openTextEditorModal = recordingOps.openTextEditorModal;
const closeTextEditorModal = recordingOps.closeTextEditorModal;
const saveTranscription = recordingOps.saveTranscription;
const openAsrEditorModal = recordingOps.openAsrEditorModal;
const closeAsrEditorModal = recordingOps.closeAsrEditorModal;
const saveAsrTranscription = recordingOps.saveAsrTranscription;
const adjustTime = recordingOps.adjustTime;
const filterSpeakers = recordingOps.filterSpeakers;
const openSpeakerSuggestions = recordingOps.openSpeakerSuggestions;
const closeSpeakerSuggestions = recordingOps.closeSpeakerSuggestions;
const selectSpeaker = recordingOps.selectSpeaker;
const addSegment = recordingOps.addSegment;
const removeSegment = recordingOps.removeSegment;
const confirmReset = recordingOps.confirmReset;
const cancelReset = recordingOps.cancelReset;
const executeReset = recordingOps.executeReset;
const searchSpeakers = recordingOps.searchSpeakers;
const selectSpeakerSuggestion = recordingOps.selectSpeakerSuggestion;
const closeSpeakerSuggestionsOnClick = recordingOps.closeSpeakerSuggestionsOnClick;
const openSpeakerModal = recordingOps.openSpeakerModal;
const closeSpeakerModal = recordingOps.closeSpeakerModal;
const saveSpeakerNames = recordingOps.saveSpeakerNames;
const highlightSpeakerInTranscript = recordingOps.highlightSpeakerInTranscript;
const focusSpeaker = recordingOps.focusSpeaker;
const blurSpeaker = recordingOps.blurSpeaker;
const clearSpeakerHighlight = recordingOps.clearSpeakerHighlight;
const navigateToNextSpeakerGroup = recordingOps.navigateToNextSpeakerGroup;
const navigateToPrevSpeakerGroup = recordingOps.navigateToPrevSpeakerGroup;
const autoIdentifySpeakers = recordingOps.autoIdentifySpeakers;
const toggleInbox = recordingOps.toggleInbox;
const toggleHighlight = recordingOps.toggleHighlight;
//--------------------录音详情高级操作 end------------------
//------------------- 深色模式与主题切换 start------------------
// --- Dark Mode ---
const toggleDarkMode = () => {
isDarkMode.value = !isDarkMode.value;
if (isDarkMode.value) {
document.documentElement.classList.add("dark");
localStorage.setItem("darkMode", "true");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("darkMode", "false");
}
};
const initializeDarkMode = () => {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const savedMode = localStorage.getItem("darkMode");
if (savedMode === "true" || (savedMode === null && prefersDark)) {
isDarkMode.value = true;
document.documentElement.classList.add("dark");
} else {
isDarkMode.value = false;
document.documentElement.classList.remove("dark");
}
};
const applyColorScheme = (schemeId, mode = null) => {
const targetMode = mode || (isDarkMode.value ? "dark" : "light");
const scheme = colorSchemes[targetMode].find((s) => s.id === schemeId);
if (!scheme) {
console.warn(
`Color scheme '${schemeId}' not found for mode '${targetMode}'`
);
return;
}
const allThemeClasses = [
...colorSchemes.light.map((s) => s.class),
...colorSchemes.dark.map((s) => s.class),
].filter((c) => c !== "");
document.documentElement.classList.remove(...allThemeClasses);
if (scheme.class) {
document.documentElement.classList.add(scheme.class);
}
currentColorScheme.value = schemeId;
localStorage.setItem("colorScheme", schemeId);
};
const initializeColorScheme = () => {
const savedScheme = localStorage.getItem("colorScheme") || "blue";
currentColorScheme.value = savedScheme;
applyColorScheme(savedScheme);
};
const openColorSchemeModal = () => {
showColorSchemeModal.value = true;
};
const closeColorSchemeModal = () => {
showColorSchemeModal.value = false;
};
const selectColorScheme = (schemeId) => {
applyColorScheme(schemeId);
showToast(
`Applied ${
colorSchemes[isDarkMode.value ? "dark" : "light"].find(
(s) => s.id === schemeId
)?.name
} theme`,
"fa-palette"
);
};
const resetColorScheme = () => {
applyColorScheme("blue");
showToast("Reset to default Ocean Blue theme", "fa-undo");
};
// Watch for dark mode changes to reapply color scheme
watch(isDarkMode, () => {
applyColorScheme(currentColorScheme.value);
});
function confirmStopRecording() {
return new Promise((resolve) => {
// 防止重复创建
if (document.getElementById("recording-confirm-mask")) {
return;
}
// 遮罩层
const mask = document.createElement("div");
mask.id = "recording-confirm-mask";
mask.style.cssText = `
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
`;
// 弹框
const dialog = document.createElement("div");
dialog.style.cssText = `
width: 360px;
background: #1f2937;
color: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,.3);
font-family: system-ui;
`;
dialog.innerHTML = `
<div style="font-size:16px;font-weight:600;margin-bottom:10px;">
确认停止录音
</div>
<div style="font-size:14px;color:#d1d5db;margin-bottom:20px;line-height:1.6;">
当前正在录音,切换页面将会停止录音,是否继续?
</div>
<div style="display:flex;justify-content:flex-end;gap:12px;">
<button id="confirm-cancel"
style="padding:6px 14px;border-radius:6px;border:1px solid #4b5563;background:#374151;color:#fff;cursor:pointer;">
继续录音
</button>
<button id="confirm-ok"
style="padding:6px 14px;border-radius:6px;border:none;background:#ef4444;color:#fff;cursor:pointer;">
停止并切换
</button>
</div>
`;
mask.appendChild(dialog);
document.body.appendChild(mask);
const cleanup = () => {
document.body.removeChild(mask);
};
dialog.querySelector("#confirm-ok").onclick = () => {
cleanup();
resolve(true);
};
dialog.querySelector("#confirm-cancel").onclick = () => {
cleanup();
resolve(false);
};
// 点击遮罩关闭 = 取消
mask.onclick = (e) => {
if (e.target === mask) {
cleanup();
resolve(false);
}
};
});
}
//--------------------深色模式与主题切换 end------------------
//------------------- 侧边栏与视图切换控制 start------------------
// --- Sidebar Toggle ---
// toggleSidebar 已移至 useSidebar
//--------------------侧边栏与视图切换控制 end------------------
//------------------- 页面视图管理 start------------------
// --- View Management ---
const switchToUploadView = async () => {
if (currentView.value === "recording" && isRec === true) {
const confirmed = await confirmStopRecording();
if (!confirmed) return;
// 清空所有录音相关状态
recordingState.discardRecordingState();
resetRealtimeTranscript({ clearTimestampBarrier: true });
currentView.value = "upload";
selectedRecording.value = null;
if (isMobileScreen.value) {
sidebar.isSidebarCollapsed.value = true;
}
} else {
// 即使不在录音中,也清空状态以准备新录音
recordingState.discardRecordingState();
resetRealtimeTranscript({ clearTimestampBarrier: true });
currentView.value = "upload";
selectedRecording.value = null;
if (isMobileScreen.value) {
sidebar.isSidebarCollapsed.value = true;
}
}
};
const selectRecording = async (recording) => {
if (currentView.value === "recording" && isRecording.value) {
// If we are in the middle of a recording, don't switch views
setGlobalError(
"请先停止当前录音,再选择其他录音。"
);
return;
}
selectedRecording.value = recording;
if (currentView.value === "recording" && isRec === true) {
const confirmed = await confirmStopRecording();
if (!confirmed) return;
if (currentView.value === "recording" && isRec === true) {
const confirmed = await confirmStopRecording();
if (!confirmed) return;
currentView.value = "detail";
} else {
currentView.value = "detail";
}
if (recording && recording.id) {
localStorage.setItem("lastSelectedRecordingId", recording.id);
} else {
localStorage.removeItem("lastSelectedRecordingId");
}
if (isMobileScreen.value) {
sidebar.isSidebarCollapsed.value = true;
}
} else {
if (currentView.value === "recording" && isRec === true) {
const confirmed = await confirmStopRecording();
if (!confirmed) return;
currentView.value = "detail";
} else {
currentView.value = "detail";
}
if (recording && recording.id) {
localStorage.setItem("lastSelectedRecordingId", recording.id);
} else {
localStorage.removeItem("lastSelectedRecordingId");
}
if (isMobileScreen.value) {
sidebar.isSidebarCollapsed.value = true;
}
}
};
//--------------------页面视图管理 end------------------
//------------------- 文件上传与上传队列 start------------------
// --- File Upload ---
const handleDragOver = (e) => {
e.preventDefault();
dragover.value = true;
};
const handleDragLeave = (e) => {
if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) {
return;
}
dragover.value = false;
};
const handleDrop = (e) => {
e.preventDefault();
dragover.value = false;
addFilesToQueue(e.dataTransfer.files);
};
const handleFileSelect = (e) => {
addFilesToQueue(e.target.files);
e.target.value = null;
};
const addFilesToQueue = (files) => {
let filesAdded = 0;
for (const file of files) {
const fileObject = file.file ? file.file : file;
const notes = file.notes || null;
const tags = file.tags || selectedTags.value || [];
const asrOptions = file.asrOptions || {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value,
};
// Check if it's an audio file or video container with audio
const isAudioFile =
fileObject &&
(fileObject.type.startsWith("audio/") ||
fileObject.type === "video/mp4" ||
fileObject.type === "video/quicktime" ||
fileObject.type === "video/x-msvideo" ||
fileObject.type === "video/webm" ||
fileObject.name.toLowerCase().endsWith(".amr") ||
fileObject.name.toLowerCase().endsWith(".3gp") ||
fileObject.name.toLowerCase().endsWith(".3gpp") ||
fileObject.name.toLowerCase().endsWith(".mp4") ||
fileObject.name.toLowerCase().endsWith(".mov") ||
fileObject.name.toLowerCase().endsWith(".avi") ||
fileObject.name.toLowerCase().endsWith(".mkv") ||
fileObject.name.toLowerCase().endsWith(".webm"));
if (isAudioFile) {
// Only check general file size limit (chunking handles OpenAI 25MB limit automatically)
if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) {
setGlobalError(
`File "${fileObject.name}" exceeds the maximum size of ${maxFileSizeMB.value} MB and was skipped.`
);
continue;
}
const clientId = `client-${Date.now()}-${Math.random()
.toString(36)
.substring(2, 9)}`;
// Auto-summarization will always occur for all uploads
const willAutoSummarize = true;
uploadQueue.value.push({
file: fileObject,
notes: notes,
tags: tags,
asrOptions: asrOptions,
status: "queued",
recordingId: null,
clientId: clientId,
error: null,
willAutoSummarize: willAutoSummarize,
});
filesAdded++;
} else if (fileObject) {
setGlobalError(
`Invalid file type "${fileObject.name}". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.`
);
}
}
if (filesAdded > 0) {
console.log(`Added ${filesAdded} file(s) to the queue.`);
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
if (!isProcessingActive.value) {
startProcessingQueue();
}
}
};
const startProcessingQueue = async () => {
console.log("Attempting to start processing queue...");
if (isProcessingActive.value) {
console.log("Queue processor already active.");
return;
}
isProcessingActive.value = true;
resetCurrentFileProcessingState();
const nextFileItem = uploadQueue.value.find(
(item) => item.status === "queued"
);
if (nextFileItem) {
console.log(
`Processing next file: ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId})`
);
currentlyProcessingFile.value = nextFileItem;
// Check if this is a "reload" item (existing recording being tracked)
if (nextFileItem.clientId.startsWith("reload-")) {
// Skip upload, go directly to polling existing recording
console.log(
`Skipping upload for existing recording: ${nextFileItem.recordingId}`
);
nextFileItem.status = "processing";
startStatusPolling(nextFileItem, nextFileItem.recordingId);
return;
}
nextFileItem.status = "uploading";
processingMessage.value = "Preparing upload...";
processingProgress.value = 5;
try {
const formData = new FormData();
formData.append("file", nextFileItem.file);
if (nextFileItem.notes) {
formData.append("notes", nextFileItem.notes);
}
// Add tags if selected (multiple tags)
// Use tags from the queue item if available, otherwise use global selectedTagIds
const tagsToUse = nextFileItem.tags || selectedTags.value || [];
tagsToUse.forEach((tag, index) => {
const tagId = tag.id || tag; // Handle both tag objects and tag IDs
formData.append(`tag_ids[${index}]`, tagId);
});
// Add ASR advanced options if ASR endpoint is enabled
if (useAsrEndpoint.value) {
// Use ASR options from the queue item if available, otherwise use global values
const asrOpts = nextFileItem.asrOptions || {};
const language = asrOpts.language || uploadLanguage.value;
const minSpeakers =
asrOpts.min_speakers || uploadMinSpeakers.value;
const maxSpeakers =
asrOpts.max_speakers || uploadMaxSpeakers.value;
if (language) {
formData.append("language", language);
}
// Only send speaker limits if they're actually set
if (minSpeakers && minSpeakers !== "") {
formData.append("min_speakers", minSpeakers.toString());
}
if (maxSpeakers && maxSpeakers !== "") {
formData.append("max_speakers", maxSpeakers.toString());
}
}
processingMessage.value = "Uploading file...";
processingProgress.value = 10;
const response = await fetch("/tool/speakr/upload", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
let errorMsg =
data.error || `Upload failed with status ${response.status}`;
if (response.status === 413)
errorMsg =
data.error ||
`File too large. Max: ${
data.max_size_mb?.toFixed(0) || maxFileSizeMB.value
} MB.`;
throw new Error(errorMsg);
}
if (response.status === 202 && data.id) {
console.log(
`File ${nextFileItem.file.name} uploaded. Recording ID: ${data.id}. Starting status poll.`
);
nextFileItem.status = "pending";
nextFileItem.recordingId = data.id;
processingMessage.value =
"Upload complete. Waiting for processing...";
processingProgress.value = 30;
recordings.value.unshift(data);
totalRecordings.value++; // Update total count
pollProcessingStatus(nextFileItem);
} else {
throw new Error(
"Unexpected success response from server after upload."
);
}
} catch (error) {
console.error(
`Upload/Processing Error for ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId}):`,
error
);
nextFileItem.status = "failed";
nextFileItem.error = error.message;
const failedRecordIndex = recordings.value.findIndex(
(r) => r.id === nextFileItem.recordingId
);
if (failedRecordIndex !== -1) {
recordings.value[failedRecordIndex].status = "FAILED";
recordings.value[
failedRecordIndex
].transcription = `Upload/Processing failed: ${error.message}`;
} else {
setGlobalError(
`Failed to process "${nextFileItem.file.name}": ${error.message}`
);
}
resetCurrentFileProcessingState();
isProcessingActive.value = false;
await nextTick();
startProcessingQueue();
}
} else {
console.log("Upload queue is empty or no files are queued.");
isProcessingActive.value = false;
}
};
const startStatusPolling = (fileItem, recordingId) => {
fileItem.recordingId = recordingId;
pollProcessingStatus(fileItem);
};
const pollProcessingStatus = (fileItem) => {
if (pollInterval.value) clearInterval(pollInterval.value);
const recordingId = fileItem.recordingId;
if (!recordingId) {
console.error(
"Cannot poll status without recording ID for",
fileItem.file.name
);
fileItem.status = "failed";
fileItem.error = "Internal error: Missing recording ID for polling.";
resetCurrentFileProcessingState();
isProcessingActive.value = false;
nextTick(startProcessingQueue);
return;
}
processingMessage.value = "Waiting for transcription...";
processingProgress.value = 40;
pollInterval.value = setInterval(async () => {
// Check if we should stop polling
const shouldStopPolling =
!currentlyProcessingFile.value ||
currentlyProcessingFile.value.clientId !== fileItem.clientId ||
fileItem.status === "failed" ||
(fileItem.status === "completed" &&
(!fileItem.willAutoSummarize || fileItem.summaryCompleted));
if (shouldStopPolling) {
console.log(
`Polling stopped for ${fileItem.clientId} as it's no longer active or finished.`
);
clearInterval(pollInterval.value);
pollInterval.value = null;
if (
currentlyProcessingFile.value &&
currentlyProcessingFile.value.clientId === fileItem.clientId
) {
resetCurrentFileProcessingState();
isProcessingActive.value = false;
await nextTick();
startProcessingQueue();
}
return;
}
try {
console.log(
`Polling status for recording ID: ${recordingId} (${fileItem.file.name})`
);
const response = await fetch(`/tool/speakr/status/${recordingId}`);
if (!response.ok)
throw new Error(
`Status check failed with status ${response.status}`
);
const data = await response.json();
const galleryIndex = recordings.value.findIndex(
(r) => r.id === recordingId
);
if (galleryIndex !== -1) {
recordings.value[galleryIndex] = data;
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value = data;
}
}
const previousStatus = fileItem.status;
fileItem.status = data.status;
fileItem.displayName = data.title || data.original_filename || fileItem.file.name;
if (data.status === "COMPLETED") {
console.log(
`Processing COMPLETED for ${fileItem.file.name} (ID: ${recordingId})`
);
// If this was previously summarizing, it's now fully complete
if (previousStatus === "summarizing") {
console.log(`Auto-summary completed for ${fileItem.file.name}`);
processingMessage.value = "Processing complete!";
processingProgress.value = 100;
fileItem.status = "completed";
fileItem.summaryCompleted = true;
// This is final completion - clean up immediately and synchronously
clearInterval(pollInterval.value);
pollInterval.value = null;
resetCurrentFileProcessingState();
isProcessingActive.value = false;
// Keep completed items visible in the modal - don't remove them
console.log(
`Completed item ${fileItem.clientId} will remain visible in queue`
);
// Use immediate startProcessingQueue instead of nextTick to avoid duplication
startProcessingQueue();
return; // Exit early to prevent further processing
}
// If auto-summarization will occur and hasn't started yet, wait for it
else if (
fileItem.willAutoSummarize &&
!fileItem.hasCheckedForAutoSummary
) {
processingMessage.value = "Transcription complete!";
processingProgress.value = 85;
fileItem.status = "awaiting_summary"; // Use intermediate status to keep it in upload queue
// Don't mark as summaryCompleted yet, continue polling
}
// No auto-summarization expected, complete normally
else {
processingMessage.value = "Processing complete!";
processingProgress.value = 100;
fileItem.status = "completed";
fileItem.summaryCompleted = true; // No summary expected, so consider it complete
// Complete immediately for files without auto-summarization
clearInterval(pollInterval.value);
pollInterval.value = null;
resetCurrentFileProcessingState();
isProcessingActive.value = false;
// Keep completed items visible in the modal - don't remove them
console.log(
`Completed item ${fileItem.clientId} will remain visible in queue`
);
startProcessingQueue();
return; // Exit early to prevent further processing
}
// For files with auto-summarization, mark that they've been checked and continue polling
if (
fileItem.willAutoSummarize &&
!fileItem.hasCheckedForAutoSummary
) {
fileItem.hasCheckedForAutoSummary = true;
fileItem.autoSummaryStartTime = Date.now();
console.log(
`Auto-summary expected for ${fileItem.file.name}, continuing to poll...`
);
// Don't complete yet, continue polling
return;
}
// If we have auto-summarization and we've been waiting, check if we should timeout
if (
fileItem.willAutoSummarize &&
fileItem.hasCheckedForAutoSummary
) {
const waitTime = Date.now() - fileItem.autoSummaryStartTime;
const maxWaitTime = 60000; // 60 seconds
if (waitTime > maxWaitTime) {
// Timeout - complete the process
console.log(
`Auto-summary timeout for ${fileItem.file.name}, completing...`
);
processingMessage.value = "Processing complete!";
processingProgress.value = 100;
fileItem.status = "completed";
fileItem.summaryCompleted = true; // Mark as complete due to timeout
clearInterval(pollInterval.value);
pollInterval.value = null;
resetCurrentFileProcessingState();
isProcessingActive.value = false;
// Keep completed items visible in the modal - don't remove them
console.log(
`Timed-out item ${fileItem.clientId} will remain visible in queue`
);
startProcessingQueue();
} else {
// Still waiting for auto-summary, continue polling
return;
}
}
// Normal completion path (no auto-summary check needed)
clearInterval(pollInterval.value);
pollInterval.value = null;
resetCurrentFileProcessingState();
isProcessingActive.value = false;
// Remove this item from uploadQueue immediately to prevent duplication
const queueIndex = uploadQueue.value.findIndex(
(item) => item.clientId === fileItem.clientId
);
if (queueIndex !== -1) {
uploadQueue.value.splice(queueIndex, 1);
console.log(
`Removed completed item ${fileItem.clientId} from queue immediately`
);
}
startProcessingQueue();
} else if (data.status === "FAILED") {
console.log(
`Processing FAILED for ${fileItem.file.name} (ID: ${recordingId})`
);
processingMessage.value = "Processing failed.";
processingProgress.value = 100;
fileItem.status = "failed";
fileItem.error =
data.transcription ||
data.summary ||
"Processing failed on server.";
setGlobalError(
`Processing failed for "${data.title || fileItem.file.name}".`
);
clearInterval(pollInterval.value);
pollInterval.value = null;
resetCurrentFileProcessingState();
isProcessingActive.value = false;
await nextTick();
startProcessingQueue();
} else if (data.status === "PROCESSING") {
// Check if this file will actually use chunking based on all conditions:
// 1. Chunking must be enabled in config
// 2. Must NOT be using ASR endpoint (ASR handles large files natively)
// 3. For size-based: File size must exceed the limit (can determine immediately)
// 4. For time-based: Can't determine client-side, but backend logs show it gets duration
const couldUseChunking =
chunkingEnabled.value && !useAsrEndpoint.value;
if (couldUseChunking) {
if (chunkingMode.value === "size") {
// Size-based chunking: we can determine definitively
const chunkThresholdBytes = chunkingLimit.value * 1024 * 1024;
const willUseChunking =
fileItem.file.size > chunkThresholdBytes;
if (willUseChunking) {
processingMessage.value =
"Processing large file (chunking in progress)...";
// If auto-summarization will occur, cap at 70%, otherwise 80%
const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
processingProgress.value = Math.round(
Math.min(
maxProgress,
processingProgress.value + Math.random() * 3
)
);
} else {
processingMessage.value = "转录进行中...";
// If auto-summarization will occur, cap at 65%, otherwise 75%
const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
processingProgress.value = Math.round(
Math.min(
maxProgress,
processingProgress.value + Math.random() * 5
)
);
}
} else {
// Duration-based chunking: Backend determines this after getting duration
// Show a neutral processing message since we can't know client-side
processingMessage.value =
"Processing file (chunking determined server-side)...";
const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
processingProgress.value = Math.round(
Math.min(
maxProgress,
processingProgress.value + Math.random() * 3
)
);
}
} else {
processingMessage.value = "转录进行中...";
const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
processingProgress.value = Math.round(
Math.min(
maxProgress,
processingProgress.value + Math.random() * 5
)
);
}
} else if (data.status === "SUMMARIZING") {
console.log(`Auto-summary started for ${fileItem.file.name}`);
processingMessage.value = "Generating summary...";
processingProgress.value = 90;
fileItem.status = "summarizing";
} else {
processingMessage.value = "Waiting in queue...";
processingProgress.value = 45;
}
} catch (error) {
console.error(
`Polling Error for ${fileItem.file.name} (ID: ${recordingId}):`,
error
);
fileItem.status = "failed";
fileItem.error = `Error checking status: ${error.message}`;
setGlobalError(
`Error checking status for "${fileItem.file.name}": ${error.message}.`
);
const galleryIndex = recordings.value.findIndex(
(r) => r.id === recordingId
);
if (galleryIndex !== -1)
recordings.value[galleryIndex].status = "FAILED";
clearInterval(pollInterval.value);
pollInterval.value = null;
resetCurrentFileProcessingState();
isProcessingActive.value = false;
await nextTick();
startProcessingQueue();
}
}, 5000);
};
//--------------------文件上传与上传队列 end------------------
//------------------- 录音与标签数据加载 start------------------
// --- Data Loading ---
const loadRecordings = async (
page = 1,
append = false,
searchQuery = ""
) => {
globalError.value = null;
if (!append) {
isLoadingRecordings.value = true;
} else {
isLoadingMore.value = true;
}
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: perPage.value.toString(),
});
if (searchQuery.trim()) {
params.set("q", searchQuery.trim());
}
const response = await fetch(`/tool/speakr/api/recordings?${params}`);
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to load recordings");
// Update pagination state
currentPage.value = data.pagination.page;
totalRecordings.value = data.pagination.total;
totalPages.value = data.pagination.total_pages;
hasNextPage.value = data.pagination.has_next;
hasPrevPage.value = data.pagination.has_prev;
// Update recordings data
if (append) {
// Append to existing recordings (infinite scroll)
recordings.value = [...recordings.value, ...data.recordings];
} else {
// Replace recordings (fresh load or search)
recordings.value = data.recordings;
// Try to restore last selected recording
const lastRecordingId = localStorage.getItem(
"lastSelectedRecordingId"
);
if (lastRecordingId && data.recordings.length > 0) {
const recordingToSelect = data.recordings.find(
(r) => r.id == lastRecordingId
);
if (recordingToSelect) {
selectRecording(recordingToSelect);
}
}
}
// Handle incomplete recordings for processing queue
const incompleteRecordings = data.recordings.filter((r) =>
["PENDING", "PROCESSING", "SUMMARIZING"].includes(r.status)
);
if (incompleteRecordings.length > 0 && !isProcessingActive.value) {
console.warn(
`Found ${incompleteRecordings.length} incomplete recording(s) on load.`
);
for (const recording of incompleteRecordings) {
let queueItem = uploadQueue.value.find(
(item) => item.recordingId === recording.id
);
if (!queueItem) {
queueItem = {
file: {
name: recording.title || `Recording ${recording.id}`,
size: recording.file_size,
},
status: "queued",
recordingId: recording.id,
clientId: `reload-${recording.id}`,
error: null,
};
uploadQueue.value.unshift(queueItem);
if (!isProcessingActive.value) {
startProcessingQueue();
}
}
}
}
} catch (error) {
console.error("Load Recordings Error:", error);
setGlobalError(`Failed to load recordings: ${error.message}`);
if (!append) {
recordings.value = [];
}
} finally {
isLoadingRecordings.value = false;
isLoadingMore.value = false;
}
};
// Load more recordings (infinite scroll) - 已移至 useSidebar
// ================== 使用侧边栏组合式函数 ==================
// 管理与侧边栏相关的状态:筛选、排序、草稿重命名、侧边栏折叠等
// 注意:必须在 loadRecordings 定义之后调用,因为 useSidebar 内部
// 的 loadMoreRecordings 依赖 loadRecordings否则 TDZ 错误)。
// 注意:传入 safeT 而非 t因为 t = safeT 在后面(~4347行才定义。
const sidebar = useSidebar({
t: safeT,
filteredRecordings,
loadRecordings,
showToast,
currentPage,
hasNextPage,
isLoadingMore,
searchQuery,
availableTags,
draftNameInput,
});
// Search with debouncing
const performSearch = async (query = "") => {
currentPage.value = 1;
await loadRecordings(1, false, query);
};
// Debounced search function
const debouncedSearch = (query) => {
if (searchDebounceTimer.value) {
clearTimeout(searchDebounceTimer.value);
}
searchDebounceTimer.value = setTimeout(() => {
performSearch(query);
}, 300); // 300ms debounce
};
const loadTags = async () => {
try {
const response = await fetch("/tool/speakr/api/tags");
if (response.ok) {
availableTags.value = await response.json();
} else {
console.warn("Failed to load tags:", response.status);
availableTags.value = [];
}
} catch (error) {
console.warn("Error loading tags:", error);
availableTags.value = [];
}
};
const addTagToSelection = (tagId) => {
if (!selectedTagIds.value.includes(tagId)) {
selectedTagIds.value.push(tagId);
applyTagDefaults();
}
};
const removeTagFromSelection = (tagId) => {
const index = selectedTagIds.value.indexOf(tagId);
if (index > -1) {
selectedTagIds.value.splice(index, 1);
applyTagDefaults();
}
};
const applyTagDefaults = () => {
// Apply defaults from the first selected tag (highest priority)
const firstTag = selectedTags.value[0];
if (firstTag && useAsrEndpoint.value) {
if (firstTag.default_language) {
uploadLanguage.value = firstTag.default_language;
}
if (firstTag.default_min_speakers) {
uploadMinSpeakers.value = firstTag.default_min_speakers;
}
if (firstTag.default_max_speakers) {
uploadMaxSpeakers.value = firstTag.default_max_speakers;
}
}
};
// Legacy function for backward compatibility
const onTagSelected = applyTagDefaults;
// Tag helper functions
const getRecordingTags = (recording) => {
if (!recording || !recording.tags) return [];
return recording.tags || [];
};
const getAvailableTagsForRecording = (recording) => {
if (!recording || !availableTags.value) return [];
const recordingTagIds = getRecordingTags(recording).map(
(tag) => tag.id
);
return availableTags.value.filter(
(tag) => !recordingTagIds.includes(tag.id)
);
};
// Computed property for filtered available tags in the modal
const filteredAvailableTagsForModal = computed(() => {
if (!editingRecording.value) return [];
const availableTags = getAvailableTagsForRecording(
editingRecording.value
);
if (!tagSearchFilter.value) return availableTags;
const filter = tagSearchFilter.value.toLowerCase();
return availableTags.filter((tag) =>
tag.name.toLowerCase().includes(filter)
);
});
// filterByTag, clearTagFilter, buildSearchQuery, applyAdvancedFilters, clearAllFilters
// 已移至 useSidebar
const editRecordingTags = (recording) => {
editingRecording.value = recording;
selectedNewTagId.value = "";
showEditTagsModal.value = true;
};
const closeEditTagsModal = () => {
showEditTagsModal.value = false;
editingRecording.value = null;
selectedNewTagId.value = "";
tagSearchFilter.value = ""; // Clear the filter when closing
};
const addTagToRecording = async (tagId = null) => {
// Use provided tagId or fall back to selectedNewTagId
const tagToAddId = tagId || selectedNewTagId.value;
if (!tagToAddId || !editingRecording.value) return;
try {
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
const response = await fetch(
`/tool/speakr/api/recordings/${editingRecording.value.id}/tags`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken,
},
body: JSON.stringify({ tag_id: tagToAddId }),
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to add tag");
}
// Update local recording data
const tagToAdd = availableTags.value.find(
(tag) => tag.id == tagToAddId
);
if (tagToAdd) {
if (!editingRecording.value.tags) {
editingRecording.value.tags = [];
}
editingRecording.value.tags.push(tagToAdd);
// Also update in recordings list if it's a different object
const recordingInList = recordings.value.find(
(r) => r.id === editingRecording.value.id
);
if (recordingInList && recordingInList !== editingRecording.value) {
if (!recordingInList.tags) {
recordingInList.tags = [];
}
recordingInList.tags.push(tagToAdd);
}
}
selectedNewTagId.value = "";
} catch (error) {
console.error("Error adding tag to recording:", error);
setGlobalError(`Failed to add tag: ${error.message}`);
}
};
const removeTagFromRecording = async (tagId) => {
if (!editingRecording.value) return;
try {
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
const response = await fetch(
`/tool/speakr/api/recordings/${editingRecording.value.id}/tags/${tagId}`,
{
method: "DELETE",
headers: {
"X-CSRFToken": csrfToken,
},
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to remove tag");
}
// Update local recording data
editingRecording.value.tags = editingRecording.value.tags.filter(
(tag) => tag.id !== tagId
);
// Also update in recordings list if it's a different object
const recordingInList = recordings.value.find(
(r) => r.id === editingRecording.value.id
);
if (
recordingInList &&
recordingInList !== editingRecording.value &&
recordingInList.tags
) {
recordingInList.tags = recordingInList.tags.filter(
(tag) => tag.id !== tagId
);
}
} catch (error) {
console.error("Error removing tag from recording:", error);
setGlobalError(`Failed to remove tag: ${error.message}`);
}
};
//--------------------录音与标签数据加载 end------------------
// ============== 录音控制与实时采集(使用 useRecordingState 工具函数) ==============
/**
* 免责声明检查
*/
debugger;
const startRecordingWithDisclaimer = async (mode = "microphone") => {
if (recordingDisclaimer.value?.trim()) {
recordingState.showDisclaimer(mode);
} else {
await startRecordingActual(mode);
}
};
const acceptRecordingDisclaimer = async () => {
const mode = recordingState.acceptDisclaimer();
if (mode) await startRecordingActual(mode);
};
const cancelRecordingDisclaimer = () => {
recordingState.cancelDisclaimer();
};
const startRecording = startRecordingWithDisclaimer;
/**
* 实际开始录音
*/
const startRecordingActual = async (mode = "microphone") => {
recordingMode.value = mode;
try {
// 加载标签
if (availableTags.value.length === 0) {
await loadTags();
}
// 初始化录音状态
recordingState.initRecordingState();
selectedTags.value = [];
asrLanguage.value = "";
asrMinSpeakers.value = "";
asrMaxSpeakers.value = "";
isEditingTranscription.value = false;
resetRealtimeTranscript({ clearTimestampBarrier: true });
let combinedStream = null;
// 获取麦克风流
if (mode === "microphone" || mode === "both") {
if (!canRecordAudio.value) {
throw new Error("Microphone recording is not supported");
}
await recordingState.getMicrophoneStream();
showToast("Microphone access granted", "fa-microphone");
}
// 获取系统音频流
if (mode === "system" || mode === "both") {
if (!canRecordSystemAudio.value) {
throw new Error("System audio recording is not supported");
}
try {
await recordingState.getSystemAudioStream();
showToast("System audio access granted", "fa-desktop");
} catch (err) {
if (mode === "system") {
throw err;
} else {
showToast(
"System audio denied, using microphone only",
"fa-exclamation-triangle"
);
mode = "microphone";
recordingMode.value = mode;
recordingState.setSystemStream(null);
}
}
}
// 合并或选择音频流
if (recordingState.micStream && recordingState.systemStream) {
try {
combinedStream = recordingState.combineAudioStreams(recordingState.micStream, recordingState.systemStream);
showToast("Recording both microphone and system audio", "fa-microphone");
} catch (error) {
console.error("Failed to combine audio streams:", error);
combinedStream = recordingState.systemStream;
showToast(
"Failed to combine audio, using system audio only",
"fa-exclamation-triangle"
);
}
} else if (recordingState.systemStream) {
combinedStream = recordingState.createAudioOnlyStream(recordingState.systemStream);
showToast("Recording system audio only", "fa-desktop");
} else if (recordingState.micStream) {
combinedStream = recordingState.micStream;
showToast("Recording microphone only", "fa-microphone");
} else {
throw new Error("No audio streams available for recording.");
}
// 使用工具函数创建 MediaRecorder
recordingState.createMediaRecorderWithFallback(combinedStream, (bitrate) => {
actualBitrate.value = bitrate;
showToast(
bitrate <= 32000 ? "Recording: Optimized (32kbps Opus)" :
bitrate <= 64000 ? "Recording: Good quality (64kbps)" :
"Recording with browser default settings",
bitrate <= 64000 ? "fa-compress-alt" : "fa-microphone",
3000
);
});
if (!mediaRecorder.value) {
throw new Error("Failed to create MediaRecorder with any configuration");
}
console.log(`Recording with estimated bitrate: ${actualBitrate.value} bps`);
mediaRecorder.value.ondataavailable = (event) =>
audioChunks.value.push(event.data);
mediaRecorder.value.onstop = () => {
const audioBlob = new Blob(audioChunks.value, {
type: "audio/webm",
});
audioBlobURL.value = URL.createObjectURL(audioBlob);
// Stop size monitoring
stopSizeMonitoring();
// Stop all active streams
activeStreams.value.forEach((stream) => {
stream.getTracks().forEach((track) => track.stop());
});
activeStreams.value = [];
recordingState.setMicStream(null);
recordingState.setSystemStream(null);
if (audioContext.value) {
audioContext.value
.close()
.catch((e) => console.error("Error closing AudioContext:", e));
audioContext.value = null;
}
cancelAnimationFrame(animationFrameId.value);
recordingState.stopRecordingTimer();
};
// --- 设置可视化器(使用工具函数)---
if (mode === "both" && recordingState.micStream && recordingState.systemStream) {
recordingState.setupDualVisualizer(recordingState.micStream, recordingState.systemStream);
} else {
// 根据 recordingMode 选择正确的流,避免旧流残留导致选错
const visualizerStream = mode === "microphone"
? recordingState.micStream
: recordingState.systemStream;
if (visualizerStream) {
recordingState.setupSingleVisualizer(visualizerStream);
}
}
// 开始录音和计时
mediaRecorder.value.start();
isRecording.value = true;
recordingState.startRecordingTimer();
// 开始大小监控
startSizeMonitoring();
// Switch to recording view
currentView.value = "recording";
// Start visualizer(s)
drawVisualizers();
setGlobalError(null);
} catch (err) {
console.error("Error starting recording:", err);
setGlobalError(`Could not start recording: ${err.message}`);
isRecording.value = false;
// Clean up any streams that were created
activeStreams.value.forEach((stream) => {
stream.getTracks().forEach((track) => track.stop());
});
activeStreams.value = [];
}
};
// 停止录音
const stopRecording = async () => {
if (isStoppingRecording.value) {
return;
}
const hasRecordingSession =
isRecording.value ||
isPaused.value ||
audioChunks.value.length > 0 ||
!!currentDraftId.value ||
activeStreams.value.length > 0;
if (!hasRecordingSession) {
return;
}
isStoppingRecording.value = true;
try {
isRec = false;
if (mediaRecorder.value && isRecording.value) {
is_final.value = true;
mediaRecorder.value.stop();
isRecording.value = false;
stopSizeMonitoring();
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
// 停止录音计时器
if (recordingInterval.value) {
clearInterval(recordingInterval.value);
recordingInterval.value = null;
}
}
await finishRealtimeAsrPipelines(20000);
// Wait for MediaRecorder to emit the final blob.
await new Promise(resolve => setTimeout(resolve, 500));
// 如果是草稿模式,上传最后一个片段但不要 finalize
if (currentDraftId.value) {
try {
// 上传最后一个片段 (only if not already paused/uploaded)
if (!isPaused.value && audioChunks.value.length > 0) {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
const formData = new FormData();
formData.append('audio', audioBlob);
formData.append('duration', recordingTime.value - pausedDuration.value);
formData.append('transcription', getRealtimeTranscriptionForSave());
await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
method: 'POST',
body: formData
});
// 清理 audioChunks 防止重复
audioChunks.value = [];
}
// 合并音频片段并获取预览 URL (不 finalize只是下载合并后的音频用于预览)
const audioResp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/download`);
if (audioResp.ok) {
const audioBlob = await audioResp.blob();
audioBlobURL.value = URL.createObjectURL(audioBlob);
}
// 保持 currentDraftId 状态,以便用户选择上传或放弃
isPaused.value = false;
} catch (err) {
console.error('Error saving final segment:', err);
setGlobalError(`保存片段失败: ${err.message}`);
}
} else {
// 非草稿模式(直接录音),创建 audioBlobURL
if (audioChunks.value.length > 0) {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
audioBlobURL.value = URL.createObjectURL(audioBlob);
}
}
if (isMobileScreen.value) {
sidebar.isSidebarCollapsed.value = true;
}
} finally {
isStoppingRecording.value = false;
}
};
// 暂停录音
const pauseRecording = async () => {
if (!isRecording.value || isPaused.value || isPausingRecording.value) return;
isPausingRecording.value = true;
try {
// 首先停止音频数据发送,防止向已关闭的 WebSocket 发送数据
isRec = false;
// 停止实时 ASR 管线
stopRealtimeAsrPipelines();
// 停止当前 MediaRecorder
if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
mediaRecorder.value.stop();
}
// 立即停止波形图动画(不等 onstop 回调,避免 resumeRecording 创建的
// MediaRecorder 的 onstop 回调缺少 cancelAnimationFrame 导致波形图
// 在 await 期间继续运行)
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
// 等待 MediaRecorder 生成最终数据
await new Promise(resolve => setTimeout(resolve, 500));
// 如果处于编辑模式,先退出编辑模式
if (isEditingTranscription.value) {
exitEditMode();
}
// 把所有未矫正的预览文本立即拿去矫正,等待完成后加入 segmentList
// 这样暂停时所有已识别的文字都成为正式转写,恢复后不会丢失
await finalizeRemainingSentences(8000);
// 创建草稿(如果是第一次暂停)
if (!currentDraftId.value) {
const resp = await fetch('/tool/speakr/api/draft/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recording_mode: recordingMode.value,
notes: recordingNotes.value
})
});
const data = await resp.json();
if (data.success) {
currentDraftId.value = data.draft.id;
} else {
throw new Error(data.error || '创建草稿失败');
}
}
// 上传当前片段
if (audioChunks.value.length > 0) {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
const formData = new FormData();
formData.append('audio', audioBlob);
formData.append('duration', recordingTime.value - pausedDuration.value);
// 发送完整转录文本(正式转写 + 预览文本),与显示框 fullRealtimeTranscription 一致
const fullTranscription = getRealtimeTranscriptionForSave();
formData.append('transcription', fullTranscription);
await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
method: 'POST',
body: formData
});
// Clear chunks after upload to prevent duplication
audioChunks.value = [];
}
// 更新状态
isPaused.value = true;
pausedDuration.value = recordingTime.value;
// 保存完整的显示文本(正式转写 + 预览文本),恢复录音时作为基准
// asrResult 是已完成矫正的正式文本asrResultOnline/asrResultOffline 是尚未完成矫正的预览文本
// 两者都必须保存,否则恢复后预览部分的文字会丢失
pausedTranscription.value = fullRealtimeTranscription.value || '';
systemPausedTranscription.value = fullSystemRealtimeTranscription.value || '';
isRecording.value = false;
// 停止计时器和动画
recordingState.stopRecordingTimer();
cancelAnimationFrame(animationFrameId.value);
stopSizeMonitoring();
// 停止所有媒体流
activeStreams.value.forEach(stream => {
stream.getTracks().forEach(track => track.stop());
});
activeStreams.value = [];
recordingState.setMicStream(null);
recordingState.setSystemStream(null);
showToast('录音已暂停', 'fa-pause');
// 刷新草稿列表
loadDrafts();
} catch (err) {
console.error('Error pausing recording:', err);
setGlobalError(`暂停录音失败: ${err.message}`);
} finally {
isPausingRecording.value = false;
}
};
// 恢复录音
const resumeRecording = async () => {
if (!isPaused.value || !currentDraftId.value) return;
try {
// 重置音频块
audioChunks.value = [];
// 准备恢复录音保留历史转写数据segmentList包含说话人、时间戳等元数据
// 只清空内部缓冲区准备接收新数据新片段会从segmentList末尾继续追加
prepareRealtimeForResume();
isEditingTranscription.value = false;
let combinedStream = null;
const mode = recordingMode.value;
// 重新获取媒体流
if (mode === 'microphone' || mode === 'both') {
await recordingState.getMicrophoneStream();
}
if (mode === 'system' || mode === 'both') {
try {
await recordingState.getSystemAudioStream();
} catch (err) {
if (mode === 'system') throw err;
showToast('系统音频权限被拒绝,仅使用麦克风', 'fa-exclamation-triangle');
recordingMode.value = 'microphone';
}
}
// 组合音频流
if (recordingState.micStream && recordingState.systemStream) {
combinedStream = recordingState.combineAudioStreams(recordingState.micStream, recordingState.systemStream);
} else if (recordingState.systemStream) {
combinedStream = recordingState.createAudioOnlyStream(recordingState.systemStream);
} else if (recordingState.micStream) {
combinedStream = recordingState.micStream;
} else {
throw new Error('无可用音频流');
}
// 创建新的 MediaRecorder
recordingState.createMediaRecorderWithFallback(combinedStream);
mediaRecorder.value.ondataavailable = (event) => audioChunks.value.push(event.data);
mediaRecorder.value.onstop = () => {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
audioBlobURL.value = URL.createObjectURL(audioBlob);
};
// 重新设置可视化
if (mode === 'both' && recordingState.micStream && recordingState.systemStream) {
recordingState.setupDualVisualizer(recordingState.micStream, recordingState.systemStream);
} else {
const visualizerStream = recordingState.micStream || recordingState.systemStream;
if (visualizerStream) {
recordingState.setupSingleVisualizer(visualizerStream);
}
}
// 开始录音
mediaRecorder.value.start();
isPaused.value = false;
isRecording.value = true;
// 恢复计时器(从暂停时间继续)
recordingInterval.value = setInterval(() => recordingTime.value++, 1000);
// 恢复可视化
drawVisualizers();
startSizeMonitoring();
// 恢复实时 ASR 管线
console.log('Resuming realtime ASR pipeline...');
asrResultOnline.value = '';
const ret = startRealtimeAsrPipelines();
if (ret === 1) {
isRec = true;
}
showToast('录音已恢复', 'fa-play');
} catch (err) {
console.error('Error resuming recording:', err);
setGlobalError(`恢复录音失败: ${err.message}`);
}
};
// 加载草稿列表
const loadDrafts = async () => {
try {
const resp = await fetch('/tool/speakr/api/draft/list');
const data = await resp.json();
draftRecordings.value = Array.isArray(data) ? data : [];
} catch (err) {
console.error('Error loading drafts:', err);
}
};
// 继续草稿录音
const continueDraftRecording = async (draftId) => {
try {
// 获取草稿详情
const resp = await fetch(`/tool/speakr/api/draft/${draftId}`);
const draft = await resp.json();
if (draft.error) {
throw new Error(draft.error);
}
// 恢复状态
currentDraftId.value = draftId;
recordingMode.value = draft.recording_mode || 'microphone';
recordingTime.value = Math.round(draft.total_duration || 0);
pausedDuration.value = recordingTime.value;
recordingNotes.value = draft.notes || '';
// Load historical transcription into asrResult
console.log('Loading draft transcription:', draft.combined_transcription ? draft.combined_transcription.length : 0, 'chars');
asrResult.value = draft.combined_transcription || '';
asrResultOnline.value = '';
asrResultOffline.value = '';
pausedTranscription.value = draft.combined_transcription || '';
// 【行为差异 C】原 main-old.js 加载草稿只重置 segmentRenderStartIndex=0
// 新代码额外调 resetRealtimeTranscript(),会清空 pendingSentenceQueue
// 并 startNewEpoch。更彻底避免上一会话 pending 残留混入),基本是改进,
// 但若依赖 pending 队列残留需注意。
resetRealtimeTranscript({ clearTimestampBarrier: true });
// 设置暂停状态
isPaused.value = true;
isRecording.value = false;
// 切换到录音视图
currentView.value = 'recording';
showToast('草稿已加载,点击恢复继续录音', 'fa-folder-open');
} catch (err) {
console.error('Error continuing draft:', err);
setGlobalError(`加载草稿失败: ${err.message}`);
}
};
// 删除草稿
const deleteDraft = async (draftId) => {
if (!confirm('确定要删除这个草稿吗?此操作不可撤销。')) return false;
try {
const resp = await fetch(`/tool/speakr/api/draft/${draftId}`, {
method: 'DELETE'
});
const data = await resp.json();
if (data.success) {
showToast('草稿已删除', 'fa-trash');
loadDrafts();
// 如果删除的是当前草稿,重置状态
if (currentDraftId.value === draftId) {
currentDraftId.value = null;
isPaused.value = false;
pausedDuration.value = 0;
pausedTranscription.value = '';
resetRealtimeTranscript({ clearTimestampBarrier: true });
}
return true;
} else {
throw new Error(data.error);
}
} catch (err) {
console.error('Error deleting draft:', err);
setGlobalError(`删除草稿失败: ${err.message}`);
return false;
}
};
// 重命名草稿 (startDraftRename, saveDraftRename) 已移至 useSidebar
const uploadRecordedAudio = async () => {
// 如果有草稿,先获取合并后的音频文件,然后走统一上传流程
if (currentDraftId.value) {
try {
showToast('Finalizing draft...', 'fa-spinner fa-spin', 0);
const savedNotes = recordingNotes.value;
const savedTags = [...selectedTags.value];
const savedAsrOptions = {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value,
};
const resp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/finalize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: savedNotes,
tag_ids: savedTags.map((tag) => tag.id || tag),
...savedAsrOptions,
}),
});
const data = await resp.json();
if (!resp.ok || !data.success || !data.recording) {
throw new Error(data.error || 'Finalize draft failed');
}
const recording = data.recording;
const existingIndex = recordings.value.findIndex((r) => r.id === recording.id);
if (existingIndex === -1) {
recordings.value.unshift(recording);
totalRecordings.value += 1;
} else {
recordings.value[existingIndex] = recording;
}
const queueItem = {
file: {
name: recording.original_filename || recording.title || `draft-${recording.id}.webm`,
size: recording.file_size || 0,
},
notes: savedNotes,
tags: savedTags,
asrOptions: savedAsrOptions,
status: 'queued',
recordingId: recording.id,
clientId: `reload-draft-${recording.id}`,
error: null,
willAutoSummarize: true,
};
uploadQueue.value.push(queueItem);
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
currentDraftId.value = null;
pausedDuration.value = 0;
pausedTranscription.value = '';
resetRealtimeTranscript({ clearTimestampBarrier: true });
loadDrafts();
if (audioBlobURL.value) {
URL.revokeObjectURL(audioBlobURL.value);
}
audioBlobURL.value = null;
audioChunks.value = [];
isRecording.value = false;
recordingNotes.value = '';
selectedTagIds.value = [];
asrLanguage.value = '';
asrMinSpeakers.value = '';
asrMaxSpeakers.value = '';
recordingTime.value = 0;
currentView.value = 'upload';
selectedRecording.value = null;
if (!isProcessingActive.value) {
startProcessingQueue();
}
showToast('Draft submitted for processing', 'fa-check-circle');
} catch (err) {
console.error('Error finalizing draft:', err);
setGlobalError(`Upload failed: ${err.message}`);
}
return;
}
if (!audioBlobURL.value) {
setGlobalError("No recorded audio to upload.");
return;
}
const previousQueueLength = uploadQueue.value.length;
const recordedFile = new File(
audioChunks.value,
createLocalizedRecordingFilename(),
{ type: "audio/webm" }
);
addFilesToQueue([
{
file: recordedFile,
notes: recordingNotes.value,
tags: selectedTags.value,
asrOptions: {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value,
},
},
]);
if (uploadQueue.value.length === previousQueueLength) {
return;
}
const queueItem = uploadQueue.value[uploadQueue.value.length - 1];
showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0);
const waitForServerResponse = () => {
return new Promise((resolve, reject) => {
const checkInterval = setInterval(() => {
if (!queueItem || queueItem.status === 'failed') {
clearInterval(checkInterval);
reject(new Error(queueItem?.error || "Upload failed"));
return;
}
const successStates = ['pending', 'processing', 'transcribing', 'summarizing', 'completed'];
if (successStates.includes(queueItem.status)) {
clearInterval(checkInterval);
resolve(true);
}
}, 200);
});
};
try {
await waitForServerResponse();
discardRecording();
if (isRec === true) {
if (typeof stopRecording === 'function') stopRecording();
}
currentView.value = "upload";
selectedRecording.value = null;
if (isMobileScreen.value) {
sidebar.isSidebarCollapsed.value = true;
}
showToast("上传成功,正在处理中...", "fa-check-circle");
} catch (error) {
console.error("Server upload failed:", error);
showToast(`上传失败: ${error.message || '请检查网络'}`, "fa-exclamation-triangle", 4000);
}
};
const downloadRecordedAudio = () => {
if (!audioBlobURL.value) {
showToast("No recording available to download", "fa-exclamation-circle");
return;
}
const filename = createLocalizedRecordingFilename();
const a = document.createElement('a');
a.style.display = 'none';
a.href = audioBlobURL.value;
a.download = filename;
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
}, 100);
showToast("录音已开始下载", "fa-download");
};
const discardRecording = async () => {
if (currentDraftId.value) {
const deleted = await deleteDraft(currentDraftId.value);
if (!deleted) return;
}
if (audioBlobURL.value) {
URL.revokeObjectURL(audioBlobURL.value);
}
audioBlobURL.value = null;
audioChunks.value = [];
isRecording.value = false;
recordingTime.value = 0;
if (recordingInterval.value) clearInterval(recordingInterval.value);
recordingNotes.value = "";
// Clear tags and ASR options for fresh start
selectedTagIds.value = [];
asrLanguage.value = "";
asrMinSpeakers.value = "";
asrMaxSpeakers.value = "";
};
/**
* 绘制单轨音频波形图(频谱可视化)
*
* 原理:
* 1. AnalyserNode 从音频流中实时提取频率数据FFT分析
* - fftSize = 256所以 frequencyBinCount = 128
* - 数据范围 0-255表示各频率点的强度0=静音255=最大音量)
* - 数组索引 0~127 对应 低频 → 高频
*
* 2. 使用 Canvas 2D 绘制柱状图
* - 每根柱子代表一个频率点
* - 柱子高度 = 频率强度 / 2.5(缩放适应画布)
* - 柱子从底部向上绘制y = HEIGHT - barHeight
*
* 3. 视觉效果
* - 使用 CSS 变量获取主题色,适配深浅模式
* - 渐变填充:顶部主题色 → 中间悬停色 → 底部透明
*
* 数据流向:
* MediaStream → AnalyserNode → getByteFrequencyData() → Uint8Array → Canvas fillRect()
*
* @param {AnalyserNode} analyserNode - Web Audio API 频谱分析节点
* @param {HTMLCanvasElement} canvasElement - 波形图画布
*/
const drawSingleVisualizer = (analyserNode, canvasElement) => {
if (!analyserNode || !canvasElement) return;
// 获取频谱数据
// bufferLength = 128 (fftSize/2),每个元素对应一个频率段的音量
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
// 将当前时刻的频率数据填充到数组,值范围 0-255
// 0 = 该频率无声音255 = 该频率声音最大
// dataArray[0] = 最低频dataArray[127] = 最高频
analyserNode.getByteFrequencyData(dataArray);
const canvasCtx = canvasElement.getContext("2d");
const WIDTH = canvasElement.width;
const HEIGHT = canvasElement.height;
// 清空画布,准备绘制新一帧
canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
// 计算每根柱子的宽度和间距
const barWidth = (WIDTH / bufferLength) * 1.5;
let barHeight;
let x = 0; // 当前柱子的x坐标
// 从 CSS 变量获取主题色,实现深浅模式自适应
const buttonColor = getComputedStyle(document.documentElement)
.getPropertyValue("--bg-button")
.trim();
const buttonHoverColor = getComputedStyle(document.documentElement)
.getPropertyValue("--bg-button-hover")
.trim();
// 创建从上到下的渐变色
const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
if (isDarkMode.value) {
gradient.addColorStop(0, buttonColor);
gradient.addColorStop(0.6, buttonHoverColor);
gradient.addColorStop(1, "rgba(0, 0, 0, 0.2)");
} else {
gradient.addColorStop(0, buttonColor);
gradient.addColorStop(0.5, buttonHoverColor);
gradient.addColorStop(1, "rgba(0, 0, 0, 0.1)");
}
// 遍历每个频率点,绘制对应的柱子
for (let i = 0; i < bufferLength; i++) {
// 将 0-255 的数据值映射为画布高度
// 除以 2.5 是为了让波形不会顶到画布顶部,留有余量
barHeight = dataArray[i] / 2.5;
canvasCtx.fillStyle = gradient;
// fillRect(x, y, width, height) - 从 y 位置向下绘制
// 因为 Canvas 坐标系 y=0 在顶部,所以从 HEIGHT-barHeight 开始画到底部
canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
x += barWidth + 2; // 下一个柱子的位置(柱子宽 + 间隙)
}
};
/**
* 波形图动画循环控制器
*
* 原理:
* 使用 requestAnimationFrame 实现约 60fps 的流畅动画
* 每帧调用 drawSingleVisualizer 重新绘制频谱柱状图
*
* 录音模式支持:
* - 单轨模式mic/system使用单个 analyser + visualizer canvas
* - 双轨模式both同时使用 micAnalyser + systemAnalyser 分别绘制两个波形图
*
* 生命周期:
* 1. 开始录音时调用 drawVisualizers() 启动动画循环
* 2. 每帧通过 requestAnimationFrame(drawVisualizers) 递归调用
* 3. 停止/暂停录音时 isRecording.value = false动画停止
*
* 注意:
* - 必须使用 requestAnimationFrame 而非 setInterval以保证与浏览器刷新率同步
* - 动画 ID 保存在 animationFrameId.value用于暂停时 cancelAnimationFrame
*/
const drawVisualizers = () => {
// 录音停止时清理动画帧并退出
if (!isRecording.value) {
if (animationFrameId.value) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
return;
}
// 请求下一帧动画,约 16.7ms 后再次执行60fps
// 这是浏览器推荐的高性能动画方式,会自动与屏幕刷新率同步
animationFrameId.value = requestAnimationFrame(drawVisualizers);
// 根据录音模式决定绘制单轨还是双轨波形图
if (recordingMode.value === "both") {
// 双轨模式:同时显示麦克风和系统音频波形(分上下两个 canvas
drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
} else {
// 单轨模式:只显示当前录制的音源波形
drawSingleVisualizer(analyser.value, visualizer.value);
}
};
//--------------------录音控制与实时采集 end------------------
//------------------- 录音保存与详情管理 start------------------
// --- Recording Management ---
const saveMetadata = async (recordingDataToSave) => {
globalError.value = null;
if (!recordingDataToSave || !recordingDataToSave.id) return null;
console.log("Saving metadata for:", recordingDataToSave.id);
try {
const payload = {
id: recordingDataToSave.id,
title: recordingDataToSave.title,
participants: recordingDataToSave.participants,
notes: recordingDataToSave.notes,
summary: recordingDataToSave.summary,
meeting_date: recordingDataToSave.meeting_date,
};
const response = await fetch("/tool/speakr/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to save metadata");
console.log("Save successful:", data.recording.id);
const index = recordings.value.findIndex(
(r) => r.id === data.recording.id
);
if (index !== -1) {
recordings.value[index].title = payload.title;
recordings.value[index].participants = payload.participants;
recordings.value[index].notes = payload.notes;
recordings.value[index].notes_html = data.recording.notes_html;
recordings.value[index].summary = payload.summary;
recordings.value[index].summary_html = data.recording.summary_html;
recordings.value[index].meeting_date = payload.meeting_date;
}
if (selectedRecording.value?.id === data.recording.id) {
selectedRecording.value.title = payload.title;
selectedRecording.value.participants = payload.participants;
selectedRecording.value.notes = payload.notes;
selectedRecording.value.notes_html = data.recording.notes_html;
selectedRecording.value.summary = payload.summary;
selectedRecording.value.summary_html = data.recording.summary_html;
selectedRecording.value.meeting_date = payload.meeting_date;
}
return data.recording;
} catch (error) {
console.error("Save Metadata Error:", error);
setGlobalError(`Save failed: ${error.message}`);
return null;
}
};
const editRecording = (recording) => {
editingRecording.value = JSON.parse(JSON.stringify(recording));
showEditModal.value = true;
};
const cancelEdit = () => {
showEditModal.value = false;
editingRecording.value = null;
};
const saveEdit = async () => {
const success = await saveMetadata(editingRecording.value);
if (success) {
cancelEdit();
}
};
const confirmDelete = (recording) => {
recordingToDelete.value = recording;
showDeleteModal.value = true;
};
const cancelDelete = () => {
showDeleteModal.value = false;
recordingToDelete.value = null;
};
const deleteRecording = async () => {
globalError.value = null;
if (!recordingToDelete.value) return;
const idToDelete = recordingToDelete.value.id;
const titleToDelete = recordingToDelete.value.title;
try {
const response = await fetch(`/tool/speakr/recording/${idToDelete}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to delete recording");
recordings.value = recordings.value.filter(
(r) => r.id !== idToDelete
);
totalRecordings.value--; // Update total count
const queueIndex = uploadQueue.value.findIndex(
(item) => item.recordingId === idToDelete
);
if (queueIndex !== -1) {
const deletedItem = uploadQueue.value.splice(queueIndex, 1)[0];
console.log(`Removed item ${deletedItem.clientId} from queue.`);
if (
currentlyProcessingFile.value?.clientId === deletedItem.clientId
) {
console.log(
`Deleting currently processing file: ${titleToDelete}. Stopping poll and moving to next.`
);
clearInterval(pollInterval.value);
pollInterval.value = null;
resetCurrentFileProcessingState();
isProcessingActive.value = false;
await nextTick();
startProcessingQueue();
}
}
if (selectedRecording.value?.id === idToDelete)
selectedRecording.value = null;
cancelDelete();
console.log(
`Successfully deleted recording ${idToDelete} (${titleToDelete})`
);
} catch (error) {
console.error("Delete Error:", error);
setGlobalError(
`Failed to delete recording "${titleToDelete}": ${error.message}`
);
cancelDelete();
}
};
//--------------------录音保存与详情管理 end------------------
//------------------- 详情页内联编辑 start------------------
// --- Inline Editing ---
const toggleEditParticipants = () => {
editingParticipants.value = !editingParticipants.value;
if (!editingParticipants.value) {
saveInlineEdit("participants");
}
};
const toggleEditMeetingDate = () => {
editingMeetingDate.value = !editingMeetingDate.value;
if (!editingMeetingDate.value) {
saveInlineEdit("meeting_date");
}
};
// 点击编辑icon 编辑摘要
const toggleEditSummary = () => {
editingSummary.value = !editingSummary.value;
if (editingSummary.value) {
// Store current content in temp variable
tempSummaryContent.value = selectedRecording.value.summary || "";
nextTick(() => {
initializeSummaryMarkdownEditor();
});
}
};
const cancelEditSummary = () => {
if (summaryMarkdownEditorInstance.value) {
summaryMarkdownEditorInstance.value.toTextArea();
summaryMarkdownEditorInstance.value = null;
}
editingSummary.value = false;
// Restore original content
if (selectedRecording.value) {
selectedRecording.value.summary = tempSummaryContent.value;
}
};
const saveEditSummary = async () => {
if (summaryMarkdownEditorInstance.value) {
selectedRecording.value.summary =
summaryMarkdownEditorInstance.value.value();
summaryMarkdownEditorInstance.value.toTextArea();
summaryMarkdownEditorInstance.value = null;
}
editingSummary.value = false;
await saveInlineEdit("summary");
};
const toggleEditNotes = () => {
editingNotes.value = !editingNotes.value;
if (editingNotes.value) {
// Store current content in temp variable
tempNotesContent.value = selectedRecording.value.notes || "";
// Initialize markdown editor when entering edit mode
nextTick(() => {
initializeMarkdownEditor();
});
}
};
const cancelEditNotes = () => {
if (markdownEditorInstance.value) {
markdownEditorInstance.value.toTextArea();
markdownEditorInstance.value = null;
}
editingNotes.value = false;
// Restore original content
if (selectedRecording.value) {
selectedRecording.value.notes = tempNotesContent.value;
}
};
const saveEditNotes = async () => {
if (markdownEditorInstance.value) {
// Get the markdown content from the editor
selectedRecording.value.notes = markdownEditorInstance.value.value();
markdownEditorInstance.value.toTextArea();
markdownEditorInstance.value = null;
}
editingNotes.value = false;
await saveInlineEdit("notes");
};
const clickToEditNotes = () => {
// Allow clicking on empty notes area to start editing
if (
!editingNotes.value &&
(!selectedRecording.value?.notes ||
selectedRecording.value.notes.trim() === "")
) {
toggleEditNotes();
}
};
const clickToEditSummary = () => {
// Allow clicking on empty summary area to start editing
if (
!editingSummary.value &&
(!selectedRecording.value?.summary ||
selectedRecording.value.summary.trim() === "")
) {
toggleEditSummary();
}
};
const autoSaveNotes = async () => {
if (markdownEditorInstance.value && editingNotes.value) {
// Just save the content to the model, don't exit edit mode
selectedRecording.value.notes = markdownEditorInstance.value.value();
// Silently save to backend without changing UI state
try {
const payload = {
id: selectedRecording.value.id,
title: selectedRecording.value.title,
participants: selectedRecording.value.participants,
notes: selectedRecording.value.notes,
summary: selectedRecording.value.summary,
meeting_date: selectedRecording.value.meeting_date,
};
const response = await fetch("/tool/speakr/save", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok && data.recording) {
// Update the HTML rendered versions if they exist
if (data.recording.notes_html) {
selectedRecording.value.notes_html = data.recording.notes_html;
}
} else {
console.error("Failed to auto-save notes");
}
} catch (error) {
console.error("Error auto-saving notes:", error);
}
}
};
const autoSaveSummary = async () => {
if (summaryMarkdownEditorInstance.value && editingSummary.value) {
// Just save the content to the model, don't exit edit mode
selectedRecording.value.summary =
summaryMarkdownEditorInstance.value.value();
// Silently save to backend without changing UI state
try {
const payload = {
id: selectedRecording.value.id,
title: selectedRecording.value.title,
participants: selectedRecording.value.participants,
notes: selectedRecording.value.notes,
summary: selectedRecording.value.summary,
meeting_date: selectedRecording.value.meeting_date,
};
const response = await fetch("/tool/speakr/save", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok && data.recording) {
// Update the HTML rendered versions if they exist
if (data.recording.summary_html) {
selectedRecording.value.summary_html =
data.recording.summary_html;
}
} else {
console.error("Failed to auto-save summary");
}
} catch (error) {
console.error("Error auto-saving summary:", error);
}
}
};
const initializeMarkdownEditor = () => {
if (!notesMarkdownEditor.value) return;
try {
markdownEditorInstance.value = new EasyMDE({
element: notesMarkdownEditor.value,
spellChecker: false,
autofocus: true,
placeholder: "以Markdown格式输入笔记...",
initialValue: selectedRecording.value?.notes || "",
status: false,
toolbar: [
"bold",
"italic",
"heading",
"|",
"quote",
"unordered-list",
"ordered-list",
"|",
"link",
"image",
"|",
"preview",
"side-by-side",
"fullscreen",
"|",
"guide",
],
previewClass: ["editor-preview", "notes-preview"],
theme: isDarkMode.value ? "dark" : "light",
});
// Add auto-save functionality
markdownEditorInstance.value.codemirror.on("change", () => {
if (autoSaveTimer.value) {
clearTimeout(autoSaveTimer.value);
}
autoSaveTimer.value = setTimeout(() => {
autoSaveNotes();
}, autoSaveDelay);
});
} catch (error) {
console.error("Failed to initialize markdown editor:", error);
// Fallback to regular textarea editing
editingNotes.value = true;
}
};
const getBaseInfo = async () => {
try {
const response = await fetch(`getUserConfig`, {
method: "GET",
});
const data = await response.json();
if (data.LEADER_SPEECH_BUTTON) {
baseInfo.value = {
name: data.LEADER_SPEECH_BUTTON,
fontSize: data.LEADER_SPEECH_TEXT_SIZE,
};
}
configwords = data.END_PHRASE_PATTERNS || "";
} catch (error) {
console.error("获取基础配置出错:", error);
configwords = "";
}
};
const initializeRecordingMarkdownEditor = () => {
if (!recordingNotesEditor.value) {
console.log("Recording notes editor ref not found");
return;
}
// Check if EasyMDE is available
if (typeof EasyMDE === "undefined") {
console.error("EasyMDE is not loaded");
return;
}
// Clean up existing instance if any
if (recordingMarkdownEditorInstance.value) {
recordingMarkdownEditorInstance.value.toTextArea();
recordingMarkdownEditorInstance.value = null;
}
try {
console.log("Initializing recording markdown editor");
recordingMarkdownEditorInstance.value = new EasyMDE({
element: recordingNotesEditor.value,
spellChecker: false,
autofocus: false,
placeholder: "Type your notes in Markdown format...",
status: false,
toolbar: [
"bold",
"italic",
"heading",
"|",
"quote",
"unordered-list",
"ordered-list",
"|",
"link",
"|",
"preview",
"guide",
],
previewClass: ["editor-preview", "notes-preview"],
theme: isDarkMode.value ? "dark" : "light",
initialValue: recordingNotes.value || "",
maxHeight: "300px", // Add height constraint to prevent unlimited growth
minHeight: "150px", // Minimum height for usability
});
// Sync changes back to the reactive variable
recordingMarkdownEditorInstance.value.codemirror.on("change", () => {
recordingNotes.value =
recordingMarkdownEditorInstance.value.value();
});
console.log("Recording markdown editor initialized successfully");
} catch (error) {
console.error(
"Failed to initialize recording markdown editor:",
error
);
// Keep as regular textarea if EasyMDE fails
}
};
// 识别启动、停止、清空操作
const toggleTranscriptionViewMode = () => {
transcriptionViewMode.value =
transcriptionViewMode.value === "simple" ? "bubble" : "simple";
localStorage.setItem(
"transcriptionViewMode",
transcriptionViewMode.value
);
};
const toggleChatMaximize = () => {
if (isChatMaximized.value) {
// If maximized, restore to normal state
isChatMaximized.value = false;
} else {
// If not maximized, maximize and ensure chat is open
isChatMaximized.value = true;
if (!showChat.value) {
showChat.value = true;
}
}
};
const saveInlineEdit = async (field) => {
if (!selectedRecording.value) return;
const fullPayload = {
id: selectedRecording.value.id,
title: selectedRecording.value.title,
participants: selectedRecording.value.participants,
notes: selectedRecording.value.notes,
summary: selectedRecording.value.summary,
meeting_date: selectedRecording.value.meeting_date,
};
try {
const updatedRecording = await saveMetadata(fullPayload);
if (updatedRecording) {
if (field === "notes") {
selectedRecording.value.notes_html = updatedRecording.notes_html;
} else if (field === "summary") {
selectedRecording.value.summary_html =
updatedRecording.summary_html;
}
switch (field) {
case "participants":
editingParticipants.value = false;
break;
case "meeting_date":
editingMeetingDate.value = false;
break;
case "summary":
editingSummary.value = false;
break;
case "notes":
editingNotes.value = false;
break;
}
showToast(
`${
field.charAt(0).toUpperCase() + field.slice(1).replace("_", " ")
} updated successfully`
);
}
} catch (error) {
console.error(`Save ${field} Error:`, error);
setGlobalError(`Failed to save ${field}: ${error.message}`);
}
};
//--------------------详情页内联编辑 end------------------
//------------------- 聊天问答功能 start------------------
// --- Chat Functionality ---
// Helper function to check if chat is scrolled to bottom (within bottom 5%)
const isChatScrolledToBottom = () => {
if (!chatMessagesRef.value) return true;
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
const scrollableHeight = scrollHeight - clientHeight;
if (scrollableHeight <= 0) return true; // No scrolling possible
const scrollPercentage = scrollTop / scrollableHeight;
return scrollPercentage >= 0.95; // Within bottom 5%
};
// Helper function to scroll chat to bottom with smooth behavior
const scrollChatToBottom = () => {
if (chatMessagesRef.value) {
requestAnimationFrame(() => {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop =
chatMessagesRef.value.scrollHeight;
}
});
}
};
const sendChatMessage = async () => {
if (
!chatInput.value.trim() ||
isChatLoading.value ||
!selectedRecording.value ||
selectedRecording.value.status !== "COMPLETED"
) {
return;
}
const message = chatInput.value.trim();
if (!Array.isArray(chatMessages.value)) {
chatMessages.value = [];
}
chatMessages.value.push({ role: "user", content: message });
chatInput.value = "";
isChatLoading.value = true;
await nextTick();
// Always scroll to bottom when user sends a new message
scrollChatToBottom();
let assistantMessage = null;
try {
const messageHistory = chatMessages.value
.slice(0, -1)
.map((msg) => ({ role: msg.role, content: msg.content }));
const response = await fetch("/tool/speakr/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recording_id: selectedRecording.value.id,
message: message,
message_history: messageHistory,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to get chat response");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processStream = async () => {
let isFirstChunk = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const jsonStr = line.substring(6);
if (jsonStr) {
try {
const data = JSON.parse(jsonStr);
if (data.thinking) {
// Check scroll position BEFORE updating content
const shouldScroll = isChatScrolledToBottom();
if (isFirstChunk) {
isChatLoading.value = false;
assistantMessage = reactive({
role: "assistant",
content: "",
html: "",
thinking: data.thinking,
thinkingExpanded: false,
});
chatMessages.value.push(assistantMessage);
isFirstChunk = false;
} else if (assistantMessage) {
// Append to existing thinking content
if (assistantMessage.thinking) {
assistantMessage.thinking += "\n\n" + data.thinking;
} else {
assistantMessage.thinking = data.thinking;
}
}
// Scroll if we were at bottom before the update
if (shouldScroll) {
await nextTick();
scrollChatToBottom();
}
}
if (data.delta) {
// Check scroll position BEFORE updating content
const shouldScroll = isChatScrolledToBottom();
if (isFirstChunk) {
isChatLoading.value = false;
assistantMessage = reactive({
role: "assistant",
content: "",
html: "",
thinking: "",
thinkingExpanded: false,
});
chatMessages.value.push(assistantMessage);
isFirstChunk = false;
}
assistantMessage.content += data.delta;
assistantMessage.html = marked.parse(
assistantMessage.content
);
// Scroll if we were at bottom before the update
if (shouldScroll) {
await nextTick();
scrollChatToBottom();
}
}
if (data.end_of_stream) {
return;
}
if (data.error) {
throw new Error(data.error);
}
} catch (e) {
console.error("Error parsing stream data:", e);
}
}
}
}
}
};
await processStream();
} catch (error) {
console.error("Chat Error:", error);
if (assistantMessage) {
assistantMessage.content = `Error: ${error.message}`;
assistantMessage.html = `<span class="text-red-500">Error: ${error.message}</span>`;
} else {
chatMessages.value.push({
role: "assistant",
content: `Error: ${error.message}`,
html: `<span class="text-red-500">Error: ${error.message}</span>`,
});
}
} finally {
isChatLoading.value = false;
await nextTick();
// Final scroll only if user is at bottom
if (isChatScrolledToBottom()) {
scrollChatToBottom();
}
}
};
//--------------------聊天问答功能 end------------------
//------------------- 分栏拖拽与尺寸持久化 start------------------
// --- Column Resizing ---
const startColumnResize = (event) => {
isResizing.value = true;
const startX = event.clientX;
const startLeftWidth = leftColumnWidth.value;
const handleMouseMove = (e) => {
if (!isResizing.value) return;
const container = document.getElementById("mainContentColumns");
if (!container) return;
const containerRect = container.getBoundingClientRect();
const deltaX = e.clientX - startX;
const containerWidth = containerRect.width;
const deltaPercent = (deltaX / containerWidth) * 100;
let newLeftWidth = startLeftWidth + deltaPercent;
newLeftWidth = Math.max(20, Math.min(80, newLeftWidth)); // Constrain between 20% and 80%
leftColumnWidth.value = newLeftWidth;
rightColumnWidth.value = 100 - newLeftWidth;
};
const handleMouseUp = () => {
isResizing.value = false;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
// Save to localStorage
localStorage.setItem("transcriptColumnWidth", leftColumnWidth.value);
localStorage.setItem("summaryColumnWidth", rightColumnWidth.value);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
event.preventDefault();
};
//--------------------分栏拖拽与尺寸持久化 end------------------
//------------------- 聊天输入交互 start------------------
// --- Chat Input Handling ---
const handleChatKeydown = (event) => {
if (event.key === "Enter") {
if (event.ctrlKey || event.shiftKey) {
// Ctrl+Enter or Shift+Enter: add new line (default behavior)
return;
} else {
// Enter: send message
event.preventDefault();
sendChatMessage();
}
}
};
//--------------------聊天输入交互 end------------------
//------------------- 音频播放器辅助逻辑 start------------------
// --- Audio Player ---
const seekAudio = (time, context = "main") => {
let audioPlayer = null;
if (context === "modal") {
// The audio player in the modal has the class directly on the audio element
audioPlayer = document.querySelector(
"audio.speaker-modal-transcript"
);
} else {
audioPlayer = document.querySelector(".main-content-area audio");
}
if (audioPlayer) {
const wasPlaying = !audioPlayer.paused;
audioPlayer.currentTime = time;
if (wasPlaying) {
audioPlayer.play();
}
} else {
console.warn(`Audio player not found for context: ${context}`);
// Fallback to old method if new one fails
const oldPlayer = document.querySelector("audio");
if (oldPlayer) {
const wasPlaying = !oldPlayer.paused;
oldPlayer.currentTime = time;
if (wasPlaying) {
oldPlayer.play();
}
}
}
};
const seekAudioFromEvent = (event) => {
const segmentElement = event.target.closest("[data-start-time]");
if (!segmentElement) return;
const time = parseFloat(segmentElement.dataset.startTime);
if (isNaN(time)) return;
// Determine context by checking if we're inside the speaker modal
const isInSpeakerModal =
event.target.closest(".speaker-modal-transcript") !== null;
const context = isInSpeakerModal ? "modal" : "main";
seekAudio(time, context);
};
const onPlayerVolumeChange = (event) => {
const newVolume = event.target.volume;
playerVolume.value = newVolume;
localStorage.setItem("playerVolume", newVolume);
};
//--------------------音频播放器辅助逻辑 end------------------
//------------------- 国际化辅助方法 start------------------
// --- i18n Helper Functions ---
// Use the same safeT function that's globally available
const t = safeT;
const tc = (key, count, params = {}) => {
return window.i18n ? window.i18n.tc(key, count, params) : key;
};
const changeLanguage = async (langCode) => {
if (window.i18n) {
await window.i18n.setLocale(langCode);
currentLanguage.value = langCode;
const lang = availableLanguages.value.find(
(l) => l.code === langCode
);
currentLanguageName.value = lang ? lang.nativeName : "English";
showLanguageMenu.value = false;
isUserMenuOpen.value = false;
// Save preference to backend
try {
await fetch("/tool/speakr/api/user/preferences", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken.value,
},
body: JSON.stringify({ language: langCode }),
});
} catch (error) {
console.error("Failed to save language preference:", error);
}
}
};
//--------------------国际化辅助方法 end------------------
//------------------- Toast 提示与复制下载反馈 start------------------
// --- Toast Notifications ---
function showToast(
message,
icon = "fa-check-circle",
duration = 2000
) {
const toastContainer = document.getElementById("toastContainer");
const toast = document.createElement("div");
toast.className = "toast";
toast.innerHTML = `<i class="fas ${icon}"></i> ${message}`;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.classList.add("show");
}, 10);
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => {
toastContainer.removeChild(toast);
}, 300);
}, duration);
}
const animateCopyButton = (button) => {
button.classList.add("copy-success");
const originalContent = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(() => {
button.classList.remove("copy-success");
button.innerHTML = originalContent;
}, 1500);
};
const copyMessage = (text, event) => {
const button = event.currentTarget;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
showToast("Copied to clipboard!");
animateCopyButton(button);
})
.catch((err) => {
console.error("Copy failed:", err);
showToast(
"Failed to copy: " + err.message,
"fa-exclamation-circle"
);
fallbackCopyTextToClipboard(text, button);
});
} else {
fallbackCopyTextToClipboard(text, button);
}
};
const fallbackCopyTextToClipboard = (text, button = null) => {
try {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
if (successful) {
showToast("Copied to clipboard!");
if (button) animateCopyButton(button);
} else {
showToast(
"Copy failed. Your browser may not support this feature.",
"fa-exclamation-circle"
);
}
} catch (err) {
console.error("Fallback copy failed:", err);
showToast("Unable to copy: " + err.message, "fa-exclamation-circle");
}
};
const copyTranscription = (event) => {
if (
!selectedRecording.value ||
!selectedRecording.value.transcription
) {
showToast(
"No transcription available to copy.",
"fa-exclamation-circle"
);
return;
}
const button = event.currentTarget;
let textToCopy = "";
try {
const transcriptionData = JSON.parse(
selectedRecording.value.transcription
);
if (Array.isArray(transcriptionData)) {
const wasDiarized = transcriptionData.some(
(segment) => segment.speaker
);
if (wasDiarized) {
textToCopy = transcriptionData
.map((segment) => {
const speakerName = segment.speaker;
return `[${speakerName}]: ${segment.sentence}`;
})
.join("\n");
} else {
textToCopy = transcriptionData
.map((segment) => segment.sentence)
.join("\n");
}
} else {
textToCopy = selectedRecording.value.transcription;
}
} catch (e) {
textToCopy = selectedRecording.value.transcription;
}
animateCopyButton(button);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(textToCopy)
.then(() => {
showToast("转录内容已复制到剪贴板!");
})
.catch((err) => {
console.error("Copy failed:", err);
showToast(
"Failed to copy: " + err.message,
"fa-exclamation-circle"
);
fallbackCopyTextToClipboard(textToCopy);
});
} else {
fallbackCopyTextToClipboard(textToCopy);
}
};
const copySummary = (event) => {
if (!selectedRecording.value || !selectedRecording.value.summary) {
showToast("No summary available to copy.", "fa-exclamation-circle");
return;
}
const button = event.currentTarget;
const textToCopy = selectedRecording.value.summary;
animateCopyButton(button);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(textToCopy)
.then(() => {
showToast("摘要已复制到剪贴板!");
})
.catch((err) => {
console.error("Copy failed:", err);
showToast(
"Failed to copy: " + err.message,
"fa-exclamation-circle"
);
fallbackCopyTextToClipboard(textToCopy);
});
} else {
fallbackCopyTextToClipboard(textToCopy);
}
};
const copyNotes = (event) => {
if (!selectedRecording.value || !selectedRecording.value.notes) {
showToast("No notes available to copy.", "fa-exclamation-circle");
return;
}
const button = event.currentTarget;
const textToCopy = selectedRecording.value.notes;
animateCopyButton(button);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(textToCopy)
.then(() => {
showToast("Notes copied to clipboard!");
})
.catch((err) => {
console.error("Copy failed:", err);
showToast(
"Failed to copy: " + err.message,
"fa-exclamation-circle"
);
fallbackCopyTextToClipboard(textToCopy);
});
} else {
fallbackCopyTextToClipboard(textToCopy);
}
};
const downloadSummary = async () => {
if (!selectedRecording.value || !selectedRecording.value.summary) {
showToast(
"No summary available to download.",
"fa-exclamation-circle"
);
return;
}
try {
const response = await fetch(
`/tool/speakr/recording/${selectedRecording.value.id}/download/summary`
);
if (!response.ok) {
const error = await response.json();
showToast(
error.error || "Failed to download summary",
"fa-exclamation-circle"
);
return;
}
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
// Get filename from response headers or use default
const contentDisposition = response.headers.get(
"Content-Disposition"
);
const filename = getDownloadFilenameFromHeaders(
contentDisposition,
"摘要.docx"
);
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast("Summary downloaded successfully!");
} catch (error) {
console.error("Download failed:", error);
showToast("Failed to download summary", "fa-exclamation-circle");
}
};
const downloadTranscript = async () => {
if (
!selectedRecording.value ||
!selectedRecording.value.transcription
) {
showToast(
"No transcription available to download.",
"fa-exclamation-circle"
);
return;
}
try {
// First, fetch available templates
const templatesResponse = await fetch(
"/tool/speakr/api/transcript-templates"
);
let templates = [];
if (templatesResponse.ok) {
templates = await templatesResponse.json();
}
// If there are templates, show a selection dialog
let templateId = null;
if (templates.length > 0) {
// Create a simple modal for template selection
const modal = document.createElement("div");
modal.className =
"fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50";
modal.innerHTML = `
<div class="bg-[var(--bg-secondary)] rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4">Select Template</h3>
<div class="space-y-2 max-h-60 overflow-y-auto">
${templates
.map(
(t) => `
<button class="template-option w-full text-left p-3 rounded border border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)] ${
t.is_default
? "ring-2 ring-[var(--ring-focus)]"
: ""
}" data-template-id="${t.id}">
<div class="font-medium">${
t.name
}</div>
${
t.description
? `<div class="text-sm text-[var(--text-muted)]">${t.description}</div>`
: ""
}
${
t.is_default
? '<div class="text-xs text-[var(--text-accent)] mt-1"><i class="fas fa-star mr-1"></i>Default</div>'
: ""
}
</button>
`
)
.join("")}
</div>
<div class="mt-4 flex gap-2">
<button class="cancel-btn px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-accent-light)]">取消</button>
<button class="download-without-template-btn px-4 py-2 bg-[var(--bg-accent)] text-white rounded hover:bg-[var(--bg-accent-hover)]">Download without template</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Wait for user selection
await new Promise((resolve) => {
modal.querySelectorAll(".template-option").forEach((btn) => {
btn.addEventListener("click", () => {
templateId = btn.dataset.templateId;
modal.remove();
resolve();
});
});
modal
.querySelector(".cancel-btn")
.addEventListener("click", () => {
templateId = "cancelled"; // Mark as cancelled
modal.remove();
resolve();
});
modal
.querySelector(".download-without-template-btn")
.addEventListener("click", () => {
templateId = "none"; // Use 'none' to indicate no template
modal.remove();
resolve();
});
modal.addEventListener("click", (e) => {
if (e.target === modal) {
templateId = "cancelled"; // Mark as cancelled when clicking outside
modal.remove();
resolve();
}
});
});
if (
templateId === null ||
templateId === undefined ||
templateId === "cancelled"
) {
// User cancelled
return;
}
}
// Download the transcript with the selected template
const url = templateId && templateId !== "none"
? `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}`
: `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=none`;
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to download transcript");
}
const blob = await response.blob();
const contentDisposition = response.headers.get(
"content-disposition"
);
const filename = getDownloadFilenameFromHeaders(
contentDisposition,
"转录稿.docx"
);
// Create download link
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
showToast("Transcript downloaded successfully!");
} catch (error) {
console.error("Error downloading transcript:", error);
showToast("Failed to download transcript", "fa-exclamation-circle");
}
};
const downloadNotes = async () => {
if (!selectedRecording.value || !selectedRecording.value.notes) {
showToast("No notes available to download.", "fa-exclamation-circle");
return;
}
try {
const response = await fetch(
`/tool/speakr/recording/${selectedRecording.value.id}/download/notes`
);
if (!response.ok) {
const error = await response.json();
showToast(
error.error || "Failed to download notes",
"fa-exclamation-circle"
);
return;
}
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
// Get filename from response headers or use default
const contentDisposition = response.headers.get(
"Content-Disposition"
);
const filename = getDownloadFilenameFromHeaders(
contentDisposition,
"笔记.docx"
);
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast("Notes downloaded successfully!", "fa-check-circle");
} catch (error) {
console.error("Download failed:", error);
showToast("Failed to download notes", "fa-exclamation-circle");
}
};
const downloadEventICS = async (event) => {
if (!event || !event.id) {
showToast("Invalid event data", "fa-exclamation-circle");
return;
}
try {
const response = await fetch(
`/tool/speakr/api/event/${event.id}/ics`
);
if (!response.ok) {
const error = await response.json();
showToast(
error.error || "Failed to download event",
"fa-exclamation-circle"
);
return;
}
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = `${event.title || "event"}.ics`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast(
`Event "${event.title}" downloaded. Open the file to add to your calendar.`,
"fa-calendar-check",
3000
);
} catch (error) {
console.error("Download failed:", error);
showToast("Failed to download event", "fa-exclamation-circle");
}
};
const downloadICS = async () => {
if (
!selectedRecording.value ||
!selectedRecording.value.events ||
selectedRecording.value.events.length === 0
) {
showToast("No events to export", "fa-exclamation-circle");
return;
}
try {
const response = await fetch(
`/tool/speakr/api/recording/${selectedRecording.value.id}/events/ics`
);
if (!response.ok) {
const error = await response.json();
showToast(
error.error || "Failed to export events",
"fa-exclamation-circle"
);
return;
}
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = `events-${
selectedRecording.value.title || selectedRecording.value.id
}.ics`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast(
`Exported ${selectedRecording.value.events.length} events`,
"fa-calendar-check"
);
} catch (error) {
console.error("Download all events ICS error:", error);
showToast("Failed to export events", "fa-exclamation-circle");
}
};
const formatEventDateTime = (dateTimeStr) => {
if (!dateTimeStr) return "";
try {
const date = new Date(dateTimeStr);
const options = {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
};
return date.toLocaleString(undefined, options);
} catch (e) {
return dateTimeStr;
}
};
const downloadChat = async () => {
if (!selectedRecording.value || chatMessages.value.length === 0) {
showToast("No chat messages to download.", "fa-exclamation-circle");
return;
}
try {
const response = await fetch(
`/tool/speakr/recording/${selectedRecording.value.id}/download/chat`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken.value,
},
body: JSON.stringify({
messages: chatMessages.value,
}),
}
);
if (!response.ok) {
const error = await response.json();
showToast(
error.error || "Failed to download chat",
"fa-exclamation-circle"
);
return;
}
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
// Get filename from response headers or use default
const contentDisposition = response.headers.get(
"Content-Disposition"
);
const filename = getDownloadFilenameFromHeaders(
contentDisposition,
"对话记录.docx"
);
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast("Notes downloaded successfully!");
} catch (error) {
console.error("Download failed:", error);
showToast("Failed to download notes", "fa-exclamation-circle");
}
};
const clearChat = () => {
if (chatMessages.value.length > 0) {
chatMessages.value = [];
showToast("Chat cleared", "fa-broom");
}
};
const openShareModal = async (recording) => {
recordingToShare.value = recording;
shareOptions.share_summary = true;
shareOptions.share_notes = true;
generatedShareLink.value = "";
existingShareDetected.value = false;
showShareModal.value = true;
// Check for existing share
try {
const response = await fetch(
`/tool/speakr/api/recording/${recording.id}/share`,
{
method: "GET",
}
);
const data = await response.json();
if (response.ok && data.exists) {
generatedShareLink.value = data.share_url;
existingShareDetected.value = true;
shareOptions.share_summary = data.share.share_summary;
shareOptions.share_notes = data.share.share_notes;
}
} catch (error) {
console.error("Error checking for existing share:", error);
}
};
const closeShareModal = () => {
showShareModal.value = false;
recordingToShare.value = null;
existingShareDetected.value = false;
};
const createShare = async (forceNew = false) => {
if (!recordingToShare.value) return;
try {
const response = await fetch(
`/tool/speakr/api/recording/${recordingToShare.value.id}/share`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...shareOptions,
force_new: forceNew,
}),
}
);
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to create share link");
generatedShareLink.value = data.share_url;
existingShareDetected.value = data.existing && !forceNew;
if (data.existing && !forceNew) {
// Show that we're using an existing share
showToast("Using existing share link", "fa-link");
} else {
showToast("Share link created successfully!", "fa-check-circle");
}
} catch (error) {
setGlobalError(`Failed to create share link: ${error.message}`);
}
};
const copyShareLink = () => {
if (!generatedShareLink.value) return;
navigator.clipboard.writeText(generatedShareLink.value).then(() => {
showToast("Share link copied to clipboard!");
});
};
const openSharesList = async () => {
isLoadingShares.value = true;
showSharesListModal.value = true;
try {
const response = await fetch("/tool/speakr/api/shares");
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to load shared items");
userShares.value = data;
} catch (error) {
setGlobalError(`Failed to load shared items: ${error.message}`);
} finally {
isLoadingShares.value = false;
}
};
const closeSharesList = () => {
showSharesListModal.value = false;
userShares.value = [];
};
const updateShare = async (share) => {
try {
const response = await fetch(`/tool/speakr/api/share/${share.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
share_summary: share.share_summary,
share_notes: share.share_notes,
}),
});
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to update share");
showToast("Share permissions updated.", "fa-check-circle");
} catch (error) {
setGlobalError(`Failed to update share: ${error.message}`);
}
};
const confirmDeleteShare = (share) => {
shareToDelete.value = share;
showShareDeleteModal.value = true;
};
const cancelDeleteShare = () => {
shareToDelete.value = null;
showShareDeleteModal.value = false;
};
const deleteShare = async () => {
if (!shareToDelete.value) return;
const shareId = shareToDelete.value.id;
try {
const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to delete share");
userShares.value = userShares.value.filter((s) => s.id !== shareId);
showToast("Share link deleted successfully.", "fa-check-circle");
showShareDeleteModal.value = false;
shareToDelete.value = null;
} catch (error) {
setGlobalError(`Failed to delete share: ${error.message}`);
}
};
//--------------------Toast 提示与复制下载反馈 end------------------
//------------------- 响应式监听与界面联动 start------------------
// --- Watchers ---
watch(
uploadQueue,
(newQueue, oldQueue) => {
if (
newQueue.length === 0 &&
oldQueue.length > 0 &&
!isProcessingActive.value
) {
console.log("Upload queue processing finished.");
setTimeout(() => (progressPopupMinimized.value = true), 500);
setTimeout(() => {
if (
completedInQueue.value === totalInQueue.value &&
!isProcessingActive.value
) {
progressPopupClosed.value = true;
}
}, 2000);
}
},
{ deep: true }
);
// Watch for changes to speakerMap to handle "This is Me" functionality
watch(
speakerMap,
(newSpeakerMap) => {
if (!newSpeakerMap) return;
Object.keys(newSpeakerMap).forEach((speakerId) => {
const speakerData = newSpeakerMap[speakerId];
if (
speakerData.isMe &&
currentUserName.value &&
!speakerData.name
) {
// Automatically fill in the user's name when "This is Me" is checked
speakerData.name = currentUserName.value;
} else if (
!speakerData.isMe &&
speakerData.name === currentUserName.value
) {
// Clear the name if "This is Me" is unchecked and the name matches the current user
speakerData.name = "";
}
});
},
{ deep: true }
);
// Watch for tab changes to save content properly
watch(selectedTab, (newTab, oldTab) => {
// Close maximized chat when switching tabs
if (isChatMaximized.value) {
isChatMaximized.value = false;
}
// Save content when switching away from notes tab but keep editor open
if (
oldTab === "notes" &&
editingNotes.value &&
markdownEditorInstance.value
) {
// Save the current content from the editor
const currentContent = markdownEditorInstance.value.value();
selectedRecording.value.notes = currentContent;
// Call auto-save instead of saveInlineEdit to keep editor open
autoSaveNotes();
// Store that we need to recreate the editor when coming back
tempNotesContent.value = currentContent;
}
// Save content when switching away from summary tab but keep editor open
if (
oldTab === "summary" &&
editingSummary.value &&
summaryMarkdownEditorInstance.value
) {
// Save the current content from the editor
const currentContent = summaryMarkdownEditorInstance.value.value();
selectedRecording.value.summary = currentContent;
// Call auto-save instead of saveInlineEdit to keep editor open
autoSaveSummary();
// Store that we need to recreate the editor when coming back
tempSummaryContent.value = currentContent;
}
// Re-initialize editors when switching back to tabs
if (newTab === "notes" && editingNotes.value) {
// Destroy old instance if exists
if (markdownEditorInstance.value) {
markdownEditorInstance.value.toTextArea();
markdownEditorInstance.value = null;
}
// Re-initialize the editor in next tick
nextTick(() => {
initializeMarkdownEditor();
});
}
if (newTab === "summary" && editingSummary.value) {
// Destroy old instance if exists
if (summaryMarkdownEditorInstance.value) {
summaryMarkdownEditorInstance.value.toTextArea();
summaryMarkdownEditorInstance.value = null;
}
// Re-initialize the editor in next tick
nextTick(() => {
initializeSummaryMarkdownEditor();
});
}
});
// Watch for mobile tab changes similarly
watch(mobileTab, (newTab, oldTab) => {
// Save content when switching away from notes tab but keep editor open
if (
oldTab === "notes" &&
editingNotes.value &&
markdownEditorInstance.value
) {
const currentContent = markdownEditorInstance.value.value();
selectedRecording.value.notes = currentContent;
autoSaveNotes(); // Use auto-save instead
tempNotesContent.value = currentContent;
}
// Save content when switching away from summary tab but keep editor open
if (
oldTab === "summary" &&
editingSummary.value &&
summaryMarkdownEditorInstance.value
) {
const currentContent = summaryMarkdownEditorInstance.value.value();
selectedRecording.value.summary = currentContent;
autoSaveSummary(); // Use auto-save instead
tempSummaryContent.value = currentContent;
}
// Re-initialize editors when switching back to tabs on mobile
if (newTab === "notes" && editingNotes.value) {
if (markdownEditorInstance.value) {
markdownEditorInstance.value.toTextArea();
markdownEditorInstance.value = null;
}
nextTick(() => {
initializeMarkdownEditor();
});
}
if (newTab === "summary" && editingSummary.value) {
if (summaryMarkdownEditorInstance.value) {
summaryMarkdownEditorInstance.value.toTextArea();
summaryMarkdownEditorInstance.value = null;
}
nextTick(() => {
initializeSummaryMarkdownEditor();
});
}
});
watch(selectedRecording, (newVal, oldVal) => {
if (newVal?.id !== oldVal?.id) {
// Save any pending edits before switching recordings
if (
editingNotes.value &&
markdownEditorInstance.value &&
oldVal?.id
) {
selectedRecording.value = oldVal; // Temporarily restore old recording
saveEditNotes(); // This will save and cleanup
selectedRecording.value = newVal; // Switch back to new recording
}
if (
editingSummary.value &&
summaryMarkdownEditorInstance.value &&
oldVal?.id
) {
selectedRecording.value = oldVal; // Temporarily restore old recording
saveEditSummary(); // This will save and cleanup
selectedRecording.value = newVal; // Switch back to new recording
}
chatMessages.value = [];
showChat.value = false;
selectedTab.value = "summary";
editingParticipants.value = false;
editingMeetingDate.value = false;
editingSummary.value = false;
editingNotes.value = false;
// Fix WebM duration issue by forcing metadata load
if (newVal?.id) {
nextTick(() => {
const audioElements = document.querySelectorAll("audio");
audioElements.forEach((audio) => {
if (
audio.src &&
audio.src.includes(`tool/speakr/audio/${newVal.id}`)
) {
// For WebM files, we need to seek to end to get duration
const fixDuration = () => {
if (!isFinite(audio.duration) || audio.duration === 0) {
audio.currentTime = 1e101; // Seek to "infinity" to load duration
audio.addEventListener(
"timeupdate",
function resetTime() {
audio.currentTime = 0;
audio.removeEventListener("timeupdate", resetTime);
},
{ once: true }
);
}
};
// Try to fix duration when metadata loads
audio.addEventListener("loadedmetadata", fixDuration, {
once: true,
});
// Also try immediately in case metadata is already loaded
if (audio.readyState >= 1) {
fixDuration();
}
}
});
});
}
}
});
// 会议记录页面
//------------------- 录音视图切换与实时 ASR 管线 start------------------
watch(currentView, (newView) => {
if (newView === 'recording') {
nextTick(() => {
initializeRecordingMarkdownEditor();
getBaseInfo();
is_final.value = false;
asrResultOnline.value = '';
createRealtimeAsrPipeline();
if (!isPaused.value) {
startRealtimeAsrPipelines();
}
if (audioBlobURL.value) {
const recordingAudio = document.querySelector('audio[src*="blob:"]');
if (recordingAudio) {
const fixDuration = () => {
if (!isFinite(recordingAudio.duration) || recordingAudio.duration === 0) {
const originalTime = recordingAudio.currentTime;
recordingAudio.currentTime = 1e101;
recordingAudio.addEventListener(
'timeupdate',
function resetTime() {
recordingAudio.currentTime = originalTime;
recordingAudio.removeEventListener('timeupdate', resetTime);
},
{ once: true }
);
}
};
recordingAudio.addEventListener('loadedmetadata', fixDuration, {
once: true,
});
recordingAudio.addEventListener('canplay', fixDuration, {
once: true,
});
if (recordingAudio.readyState >= 1) {
setTimeout(fixDuration, 100);
}
}
}
});
} else {
disposeRealtimeAsrPipeline();
analysisResult.value = '';
asrResult.value = '';
pausedTranscription.value = ''; // 清空暂停时的转录文本
clearSystemRealtimeTranscript();
isEditingTranscription.value = false;
is_final.value = false;
if (recordingMarkdownEditorInstance.value) {
recordingMarkdownEditorInstance.value.toTextArea();
recordingMarkdownEditorInstance.value = null;
}
}
});
//--------------------录音视图切换与实时 ASR 管线 end------------------
// 监听内容变化
watch(asrResult, async () => {
await nextTick(); // 等 DOM 更新完再操作
if (asrTextarea.value) {
// console.log(asrTextarea.value.scrollHeight,'asrTextarea.value.scrollHeight');
asrTextarea.value.scrollTop = asrTextarea.value.scrollHeight;
}
if (asrResultTextarea.value) {
asrResultTextarea.value.scrollTop =
asrResultTextarea.value.scrollHeight;
}
});
// 监听内容变化
watch(asrResultOnline, async () => {
await nextTick(); // 等 DOM 更新完再操作
if (asrResultTextareaOnline.value) {
asrResultTextareaOnline.value.scrollTop =
asrResultTextareaOnline.value.scrollHeight;
}
});
watch(asrResultOffline, async () => {
await nextTick(); // 等 DOM 更新完再操作
if (asrResultTextareaOnline.value) {
asrResultTextareaOnline.value.scrollTop =
asrResultTextareaOnline.value.scrollHeight;
}
});
let realtimeAutoScrollFrame = null;
const scrollRealtimeTextareaToBottom = (textarea) => {
if (!textarea) return;
textarea.scrollTop = textarea.scrollHeight;
};
const scrollVisibleRealtimeTextareas = () => {
[
asrTextarea.value,
asrResultTextarea.value,
asrResultTextareaOnline.value,
systemAsrResultTextarea.value,
systemAsrResultTextareaOnline.value,
].forEach(scrollRealtimeTextareaToBottom);
};
const scheduleRealtimeAutoScroll = async (force = false) => {
if (isEditingTranscription.value && !force) {
return;
}
await nextTick();
scrollVisibleRealtimeTextareas();
if (
typeof window === "undefined" ||
typeof window.requestAnimationFrame !== "function"
) {
return;
}
if (realtimeAutoScrollFrame !== null) {
window.cancelAnimationFrame(realtimeAutoScrollFrame);
realtimeAutoScrollFrame = null;
}
realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
scrollVisibleRealtimeTextareas();
realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
scrollVisibleRealtimeTextareas();
realtimeAutoScrollFrame = null;
});
});
};
watch(fullRealtimeTranscription, () => {
scheduleRealtimeAutoScroll();
});
watch(fullSystemRealtimeTranscription, () => {
scheduleRealtimeAutoScroll();
});
watch(isEditingTranscription, () => {
scheduleRealtimeAutoScroll(true);
});
watch(isyjDialogVisible, (visible) => {
if (visible) {
scheduleRealtimeAutoScroll(true);
}
});
watch(analysisResult, async () => {
await nextTick(); // 等 DOM 更新完再操作
if (sumupResultTextarea.value) {
sumupResultTextarea.value.scrollTop =
sumupResultTextarea.value.scrollHeight;
}
});
watch(audioBlobURL, (newURL) => {
if (newURL) {
nextTick(() => {
const recordingAudio = document.querySelector(
'audio[src*="blob:"]'
);
if (recordingAudio) {
const fixDuration = () => {
if (
!isFinite(recordingAudio.duration) ||
recordingAudio.duration === 0
) {
const originalTime = recordingAudio.currentTime;
// recordingAudio.currentTime = 1e101;
recordingAudio.currentTime = originalTime + 0.01
recordingAudio.addEventListener(
"timeupdate",
function resetTime() {
recordingAudio.currentTime = originalTime;
recordingAudio.removeEventListener(
"timeupdate",
resetTime
);
},
{ once: true }
);
}
};
// Multiple events to ensure we catch the duration
recordingAudio.addEventListener("loadedmetadata", fixDuration, {
once: true,
});
recordingAudio.addEventListener("loadeddata", fixDuration, {
once: true,
});
recordingAudio.addEventListener("canplay", fixDuration, {
once: true,
});
// Also try after a delay
if (recordingAudio.readyState >= 1) {
setTimeout(fixDuration, 100);
}
}
});
}
});
watch(playerVolume, (newVolume) => {
const audioElements = document.querySelectorAll("audio");
audioElements.forEach((audio) => {
if (audio.volume !== newVolume) {
audio.volume = newVolume;
}
});
});
// Watch for search query changes
watch(searchQuery, (newQuery) => {
debouncedSearch(newQuery);
});
// 自动应用筛选的监听器已移至 useSidebar
//--------------------响应式监听与界面联动 end------------------
//------------------- 运行时配置加载 start------------------
// --- Configuration Loading ---
const loadConfiguration = async () => {
try {
const response = await fetch("/tool/speakr/api/config");
if (response.ok) {
const config = await response.json();
maxFileSizeMB.value = config.max_file_size_mb || 250;
chunkingEnabled.value =
config.chunking_enabled !== undefined
? config.chunking_enabled
: true;
chunkingMode.value = config.chunking_mode || "size";
chunkingLimit.value = config.chunking_limit || 20;
chunkingLimitDisplay.value =
config.chunking_limit_display || "20MB";
recordingDisclaimer.value = config.recording_disclaimer || "";
console.log(
`Loaded configuration: max size ${
maxFileSizeMB.value
}MB, chunking ${
chunkingEnabled.value ? "enabled" : "disabled"
} (${chunkingLimitDisplay.value})`
);
} else {
console.warn("Failed to load configuration, using default values");
}
} catch (error) {
console.error("Error loading configuration:", error);
// Keep default values on error
}
};
const initializeSummaryMarkdownEditor = () => {
if (!summaryMarkdownEditor.value) return;
try {
summaryMarkdownEditorInstance.value = new EasyMDE({
element: summaryMarkdownEditor.value,
spellChecker: false,
autofocus: true,
placeholder: "Enter summary in Markdown format...",
initialValue: selectedRecording.value?.summary || "",
status: false,
toolbar: [
"bold",
"italic",
"heading",
"|",
"quote",
"unordered-list",
"ordered-list",
"|",
"link",
"image",
"|",
"preview",
"side-by-side",
"fullscreen",
"|",
"guide",
],
previewClass: ["editor-preview", "notes-preview"],
theme: isDarkMode.value ? "dark" : "light",
});
// Add auto-save functionality
summaryMarkdownEditorInstance.value.codemirror.on("change", () => {
if (autoSaveTimer.value) {
clearTimeout(autoSaveTimer.value);
}
autoSaveTimer.value = setTimeout(() => {
autoSaveSummary();
}, autoSaveDelay);
});
} catch (error) {
console.error("Failed to initialize summary markdown editor:", error);
editingSummary.value = true;
}
};
const pollInboxRecordings = async () => {
try {
const response = await fetch("/tool/speakr/api/inbox_recordings");
if (!response.ok) {
// Silently fail, as this is a background task
return;
}
const inboxRecordings = await response.json();
if (inboxRecordings && inboxRecordings.length > 0) {
inboxRecordings.forEach((recording) => {
// Add to main recordings list if it's not there already
const existingRecording = recordings.value.find(
(r) => r.id === recording.id
);
if (!existingRecording) {
recordings.value.unshift(recording);
totalRecordings.value++; // Update total count
}
// Check if this recording is already in the upload queue or currently being processed
const existingItem = uploadQueue.value.find(
(item) => item.recordingId === recording.id
);
const isCurrentlyProcessing =
currentlyProcessingFile.value &&
currentlyProcessingFile.value.recordingId === recording.id;
// Don't add if it's already in queue or being processed
if (existingItem || isCurrentlyProcessing) {
// Update status if the existing item status has changed
if (existingItem && existingItem.status !== recording.status) {
console.log(
`Updating status for existing recording ${recording.original_filename}: ${existingItem.status} -> ${recording.status}`
);
}
return;
}
// Only add recordings that are still processing (not completed)
if (recording.status === "COMPLETED") {
console.log(
`Skipping completed inbox recording: ${recording.original_filename}`
);
return;
}
console.log(
`Found new inbox recording: ${recording.original_filename} (status: ${recording.status})`
);
// Don't add recordings that are already being handled by main processing
if (
recording.status === "SUMMARIZING" &&
isProcessingActive.value
) {
console.log(
`Skipping ${recording.original_filename} - already in summarization phase of main processing`
);
return;
}
// Add it to the queue to be displayed in the progress modal
const inboxItem = {
file: {
name:
recording.original_filename || `Recording ${recording.id}`,
size: recording.file_size || 0,
},
status: "pending", // It's already being processed on backend
recordingId: recording.id,
clientId: `inbox-${recording.id}-${Date.now()}`,
error: null,
isReprocessing: true, // Use reprocessing logic to poll for status
reprocessType: "transcription",
};
uploadQueue.value.unshift(inboxItem);
// If nothing is currently being processed, make this the active item for the main progress bar
if (!isProcessingActive.value && !currentlyProcessingFile.value) {
currentlyProcessingFile.value = inboxItem;
}
// Show progress modal if it's hidden
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
// Start polling its status
startReprocessingPoll(recording.id);
});
}
} catch (error) {
console.error("Error polling for inbox recordings:", error);
}
};
//--------------------运行时配置加载 end------------------
//------------------- 录音格式兼容检测 start------------------
// --- Audio Format Detection ---
const detectSupportedAudioFormats = () => {
const formats = [
"audio/webm;codecs=opus",
"audio/webm;codecs=vp9",
"audio/webm",
"audio/mp4;codecs=mp4a.40.2",
"audio/mp4",
"audio/ogg;codecs=opus",
"audio/wav",
];
const supportedFormats = formats.filter((format) =>
MediaRecorder.isTypeSupported(format)
);
console.log("Supported audio recording formats:", supportedFormats);
if (supportedFormats.length === 0) {
console.warn(
"No optimized audio formats supported, will use browser default"
);
}
return supportedFormats;
};
//--------------------录音格式兼容检测 end------------------
//------------------- 系统音频能力检测 start------------------
// --- System Audio Detection ---
const detectSystemAudioCapabilities = async () => {
systemAudioSupported.value = false;
canRecordSystemAudio.value = false;
systemAudioError.value = "";
// Check if getDisplayMedia is available
if (
!navigator.mediaDevices ||
!navigator.mediaDevices.getDisplayMedia
) {
systemAudioError.value = "getDisplayMedia API not supported";
return;
}
try {
// Test if we can request system audio (this will prompt user)
// We'll do this only when user actually tries to record
systemAudioSupported.value = true;
canRecordSystemAudio.value = true;
systemAudioError.value = "";
} catch (error) {
systemAudioError.value = error.message;
console.warn("System audio detection failed:", error);
}
};
const detectBrowser = () => {
const userAgent = navigator.userAgent;
if (userAgent.includes("Firefox")) {
browser.value = "firefox";
} else if (userAgent.includes("Chrome")) {
browser.value = "chrome";
} else if (userAgent.includes("Safari")) {
browser.value = "safari";
} else if (
userAgent.includes("MSIE") ||
userAgent.includes("Trident/")
) {
browser.value = "ie";
} else {
browser.value = "unknown";
}
};
//--------------------系统音频能力检测 end------------------
//------------------- 生命周期初始化 start------------------
// --- Lifecycle ---
onMounted(async () => {
const loader = document.getElementById("loader");
const app = document.getElementById("app");
if (loader) {
loader.style.opacity = "0";
setTimeout(() => {
loader.style.display = "none";
}, 500);
}
if (app) {
app.style.opacity = "1";
}
const appDiv = document.getElementById("app");
if (appDiv) {
const asrFlag = appDiv.dataset.useAsrEndpoint;
useAsrEndpoint.value = asrFlag === "True" || asrFlag === "true";
currentUserName.value = appDiv.dataset.currentUserName || "";
}
// Load configuration first
await loadConfiguration();
// Detect system audio capabilities
await detectSystemAudioCapabilities();
detectBrowser();
// Check if language was changed in account settings
if (localStorage.getItem("ui_language_changed") === "true") {
localStorage.removeItem("ui_language_changed");
// Force reload to apply new language
window.location.reload();
return;
}
// i18n is already initialized before Vue app creation
if (window.i18n) {
currentLanguage.value = window.i18n.getLocale();
availableLanguages.value = window.i18n.getAvailableLocales();
const lang = availableLanguages.value.find(
(l) => l.code === currentLanguage.value
);
currentLanguageName.value = lang ? lang.nativeName : "English";
// Listen for locale changes
window.addEventListener("localeChanged", (event) => {
currentLanguage.value = event.detail.locale;
const lang = availableLanguages.value.find(
(l) => l.code === currentLanguage.value
);
currentLanguageName.value = lang ? lang.nativeName : "English";
});
}
loadRecordings();
loadTags();
loadDrafts(); // 加载草稿列表
initializeDarkMode();
initializeColorScheme();
const savedVolume = localStorage.getItem("playerVolume");
if (savedVolume !== null) {
playerVolume.value = parseFloat(savedVolume);
}
const savedTranscriptionViewMode = localStorage.getItem(
"transcriptionViewMode"
);
if (savedTranscriptionViewMode) {
transcriptionViewMode.value = savedTranscriptionViewMode;
}
// Load saved column widths
const savedLeftWidth = localStorage.getItem("transcriptColumnWidth");
const savedRightWidth = localStorage.getItem("summaryColumnWidth");
if (savedLeftWidth && savedRightWidth) {
leftColumnWidth.value = parseFloat(savedLeftWidth);
rightColumnWidth.value = parseFloat(savedRightWidth);
}
const updateMobileStatus = () => {
windowWidth.value = window.innerWidth;
};
window.addEventListener("resize", updateMobileStatus);
updateMobileStatus();
// Start polling for inbox recordings
setInterval(pollInboxRecordings, 10000);
const handleEsc = (e) => {
if (e.key === "Escape") {
if (showColorSchemeModal.value) {
closeColorSchemeModal();
}
if (showEditModal.value) {
cancelEdit();
}
if (showDeleteModal.value) {
cancelDelete();
}
if (showSortOptions.value) {
showSortOptions.value = false;
}
}
};
document.addEventListener("keydown", handleEsc);
// Click away handler for dropdowns
const handleClickAway = (e) => {
// Close user menu if clicking outside
if (isUserMenuOpen.value) {
// Check if we clicked within the user menu area (button or dropdown)
const userMenuButton = e.target.closest(
'button[class*="flex items-center gap"]'
);
const userMenuDropdown = e.target.closest(
'div[class*="absolute right-0"]'
);
// Check if the click was on the user menu button specifically
const isUserMenuButtonClick =
userMenuButton &&
userMenuButton.querySelector("i.fa-user-circle");
// If we didn't click on the user menu button or dropdown, close it
if (!isUserMenuButtonClick && !userMenuDropdown) {
isUserMenuOpen.value = false;
}
}
// Close speaker suggestions if clicking outside in the modal
if (showSpeakerModal.value) {
// If not clicking on an input field or suggestion dropdown
const clickedOnInput = e.target.closest('input[type="text"]');
const clickedOnSuggestion = e.target.closest(
'[class*="absolute z-10"]'
);
if (!clickedOnInput && !clickedOnSuggestion) {
// Close all suggestion dropdowns
Object.keys(speakerSuggestions.value).forEach((speakerId) => {
speakerSuggestions.value[speakerId] = [];
});
}
}
};
document.addEventListener("click", handleClickAway);
window.addEventListener("beforeunload", stopDualTranscriptResize);
});
//--------------------生命周期初始化 end------------------
//------------------- 实时转写手动编辑模式 start------------------
// --- 转录文本编辑功能 ---
// 用户手动编辑后,以编辑结果为准,旧的实时结果不再回灌。
let editModeOriginalText = "";
function enterEditMode() {
editModeOriginalText = fullRealtimeTranscription.value || '';
editableText.value = editModeOriginalText;
systemEditableText.value = fullSystemRealtimeTranscription.value || '';
realtimeAsrPipeline?.enterEditMode(editModeOriginalText);
systemRealtimeAsrPipeline?.enterEditMode(systemEditableText.value);
isEditingTranscription.value = true;
}
function exitEditMode() {
const nextText = editableText.value ?? '';
const nextSystemText = systemEditableText.value ?? '';
isEditingTranscription.value = false;
if (realtimeAsrPipeline) {
realtimeAsrPipeline.applyEditedText(nextText);
} else {
pausedTranscription.value = nextText;
asrResult.value = nextText;
asrResultOnline.value = '';
asrResultOffline.value = '';
}
if (systemRealtimeAsrPipeline) {
systemRealtimeAsrPipeline.applyEditedText(nextSystemText);
} else {
systemPausedTranscription.value = nextSystemText;
systemAsrResult.value = nextSystemText;
systemAsrResultOnline.value = '';
systemAsrResultOffline.value = '';
}
editModeOriginalText = nextText;
}
//--------------------实时转写手动编辑模式 end------------------
//------------------- 模板暴露与事件出口 start------------------
return {
// Core State
currentView,
dragover,
recordings,
selectedRecording,
selectedTab,
searchQuery,
isLoadingRecordings,
globalError,
maxFileSizeMB,
chunkingEnabled,
chunkingMode,
chunkingLimit,
chunkingLimitDisplay,
sortBy: sidebar.sortBy,
showAdvancedFilters: sidebar.showAdvancedFilters,
filterTags: sidebar.filterTags,
filterDateRange: sidebar.filterDateRange,
filterDatePreset: sidebar.filterDatePreset,
filterTextQuery: sidebar.filterTextQuery,
// Pagination State
currentPage,
perPage,
totalRecordings,
totalPages,
hasNextPage,
hasPrevPage,
isLoadingMore,
// UI State
browser,
isSidebarCollapsed: sidebar.isSidebarCollapsed,
searchTipsExpanded,
isUserMenuOpen,
isDarkMode,
currentColorScheme,
showColorSchemeModal,
windowWidth,
isMobileScreen,
mobileTab,
isMetadataExpanded,
// i18n State
currentLanguage,
currentLanguageName,
availableLanguages,
showLanguageMenu,
// Upload State
uploadQueue,
currentlyProcessingFile,
processingProgress,
processingMessage,
isProcessingActive,
progressPopupMinimized,
progressPopupClosed,
totalInQueue,
completedInQueue,
finishedFilesInQueue,
clearCompletedUploads,
// Audio Recording
editingDraftId: sidebar.editingDraftId,
editingDraftName: sidebar.editingDraftName,
isRecording,
canRecordAudio,
canRecordSystemAudio,
systemAudioSupported,
systemAudioError,
audioBlobURL,
recordingTime,
recordingNotes,
visualizer,
micVisualizer,
systemVisualizer,
recordingMode,
recordingDisclaimer,
showRecordingDisclaimerModal,
acceptRecordingDisclaimer,
cancelRecordingDisclaimer,
showAdvancedOptions,
uploadLanguage,
uploadMinSpeakers,
uploadMaxSpeakers,
asrLanguage,
asrMinSpeakers,
asrMaxSpeakers,
availableTags,
selectedTagIds,
selectedTags,
uploadTagSearchFilter,
filteredAvailableTagsForUpload,
onTagSelected,
addTagToSelection,
removeTagFromSelection,
showSystemAudioHelp,
// Draft/Pause Recording
isPaused,
isStoppingRecording,
isPausingRecording,
currentDraftId,
showRecordingControls,
pausedDuration,
pausedTranscription,
draftRecordings,
draftsExpanded,
pauseRecording,
resumeRecording,
loadDrafts,
continueDraftRecording,
deleteDraft,
// Modal State
showEditModal,
showDeleteModal,
showResetModal: recordingOps.showResetModal,
editingRecording,
recordingToDelete,
showEditTagsModal,
selectedNewTagId,
tagSearchFilter,
filteredAvailableTagsForModal,
editRecordingTags,
closeEditTagsModal,
addTagToRecording,
removeTagFromRecording,
getRecordingTags,
getAvailableTagsForRecording,
filterByTag: sidebar.filterByTag,
clearTagFilter: sidebar.clearTagFilter,
applyAdvancedFilters: sidebar.applyAdvancedFilters,
clearAllFilters: sidebar.clearAllFilters,
showTextEditorModal: recordingOps.showTextEditorModal,
showAsrEditorModal: recordingOps.showAsrEditorModal,
editingTranscriptionContent: recordingOps.editingTranscriptionContent,
editingSegments: recordingOps.editingSegments,
availableSpeakers: recordingOps.availableSpeakers,
// Inline Editing
editingParticipants,
editingMeetingDate,
editingSummary,
editingNotes,
// Markdown Editor
notesMarkdownEditor,
markdownEditorInstance,
summaryMarkdownEditor,
summaryMarkdownEditorInstance,
recordingNotesEditor,
// Transcription
transcriptionViewMode,
legendExpanded,
highlightedSpeaker: recordingOps.highlightedSpeaker,
processedTranscription,
// Chat
showChat,
isChatMaximized,
toggleChatMaximize,
chatMessages,
chatInput,
isChatLoading,
chatMessagesRef,
// Audio Player
playerVolume,
// Column Resizing
leftColumnWidth,
rightColumnWidth,
isResizing,
// App Configuration
useAsrEndpoint,
currentUserName,
// Computed
filteredRecordings,
groupedRecordings: sidebar.groupedRecordings,
activeRecordingMetadata,
datePresetOptions: sidebar.datePresetOptions,
languageOptions,
// Color Schemes
colorSchemes,
asrResult,
asrResultOnline,
asrResultOffline,
fullRealtimeTranscription,
fullSystemRealtimeTranscription,
isDualRealtimeTranscription,
systemEditableText,
dualTranscriptContainer,
dualTranscriptTopPercent,
startDualTranscriptResize,
asrTextarea,
asrResultTextarea,
asrResultTextareaOnline,
systemAsrResultTextarea,
systemAsrResultTextareaOnline,
analysisResult,
sumupResultTextarea,
isDialogVisible,
isyjDialogVisible,
baseInfo,
isModalOpen,
urlinfo,
urlmsg,
zjloding,
// Methods
openmodel,
openzjmodel,
closeModal,
setGlobalError,
formatFileSize,
formatDisplayDate,
formatStatus,
getStatusClass,
formatTime,
t,
tc,
changeLanguage,
toggleDarkMode,
applyColorScheme,
initializeColorScheme,
openColorSchemeModal,
closeColorSchemeModal,
selectColorScheme,
resetColorScheme,
toggleSidebar: sidebar.toggleSidebar,
switchToUploadView,
selectRecording,
handleDragOver,
handleDragLeave,
handleDrop,
handleFileSelect,
addFilesToQueue,
startRecording,
stopRecording,
uploadRecordedAudio,
discardRecording,
downloadRecordedAudio,
loadRecordings,
loadMoreRecordings: sidebar.loadMoreRecordings,
performSearch,
debouncedSearch,
saveMetadata,
editRecording,
cancelEdit,
saveEdit,
confirmDelete,
cancelDelete,
deleteRecording,
toggleEditParticipants,
toggleEditMeetingDate,
toggleEditSummary,
cancelEditSummary,
saveEditSummary,
toggleEditNotes,
cancelEditNotes,
saveEditNotes,
initializeMarkdownEditor,
saveInlineEdit,
clickToEditNotes,
clickToEditSummary,
autoSaveNotes,
autoSaveSummary,
sendChatMessage,
isChatScrolledToBottom,
scrollChatToBottom,
startColumnResize,
handleChatKeydown,
seekAudio,
seekAudioFromEvent,
onPlayerVolumeChange,
showToast,
copyMessage,
copyTranscription,
copySummary,
copyNotes,
downloadSummary,
downloadTranscript,
downloadNotes,
downloadChat,
clearChat,
downloadEventICS,
downloadICS,
formatEventDateTime,
toggleInbox,
toggleHighlight,
toggleTranscriptionViewMode,
reprocessTranscription,
reprocessSummary,
generateSummary,
resetRecordingStatus,
confirmReset,
cancelReset,
executeReset,
openTranscriptionEditor,
openTextEditorModal,
closeTextEditorModal,
saveTranscription,
openAsrEditorModal,
closeAsrEditorModal,
saveAsrTranscription,
adjustTime,
filterSpeakers,
openSpeakerSuggestions,
closeSpeakerSuggestions,
selectSpeaker,
addSegment,
removeSegment,
showReprocessModal: recordingOps.showReprocessModal,
reprocessType: recordingOps.reprocessType,
reprocessRecording: recordingOps.reprocessRecording,
cancelReprocess,
executeReprocess,
asrReprocessOptions: recordingOps.asrReprocessOptions,
showSpeakerModal: recordingOps.showSpeakerModal,
showShareModal,
recordingToShare,
shareOptions,
generatedShareLink,
existingShareDetected,
userShares,
isLoadingShares,
showSharesListModal,
shareToDelete,
showShareDeleteModal,
confirmDeleteShare,
cancelDeleteShare,
speakerMap: recordingOps.speakerMap,
speakerDisplayMap: recordingOps.speakerDisplayMap,
modalSpeakers: recordingOps.modalSpeakers,
regenerateSummaryAfterSpeakerUpdate: recordingOps.regenerateSummaryAfterSpeakerUpdate,
identifiedSpeakers,
identifiedSpeakersInOrder: recordingOps.identifiedSpeakersInOrder,
hasSpeakerNames: recordingOps.hasSpeakerNames,
openSpeakerModal,
closeSpeakerModal,
saveSpeakerNames,
highlightedTranscript: recordingOps.highlightedTranscript,
highlightedSpeaker: recordingOps.highlightedSpeaker,
highlightSpeakerInTranscript,
focusSpeaker,
blurSpeaker,
clearSpeakerHighlight,
currentSpeakerGroupIndex: recordingOps.currentSpeakerGroupIndex,
speakerGroups: recordingOps.speakerGroups,
navigateToNextSpeakerGroup,
navigateToPrevSpeakerGroup,
speakerSuggestions: recordingOps.speakerSuggestions,
loadingSuggestions: recordingOps.loadingSuggestions,
activeSpeakerInput: recordingOps.activeSpeakerInput,
searchSpeakers,
selectSpeakerSuggestion,
closeSpeakerSuggestionsOnClick,
autoIdentifySpeakers,
isAutoIdentifying: recordingOps.isAutoIdentifying,
formatDuration,
openShareModal,
closeShareModal,
createShare,
openSharesList,
closeSharesList,
updateShare,
deleteShare,
copyShareLink,
startDraftRename: sidebar.startDraftRename,
saveDraftRename: sidebar.saveDraftRename,
editingDraftId: sidebar.editingDraftId,
editingDraftName: sidebar.editingDraftName,
draftNameInput,
draftRecordings,
deleteDraft,
continueDraftRecording,
loadDrafts,
// Recording Size Monitoring
estimatedFileSize,
fileSizeWarningShown,
recordingQuality,
actualBitrate,
maxRecordingMB,
sizeCheckInterval,
updateFileSizeEstimate,
startSizeMonitoring,
stopSizeMonitoring,
sumupResult,
lastTimer,
promptVisible,
promptList,
openPromptModal,
closePromptModal,
confirmPromptSelection,
selectedPromptId,
recommendPromptId,
recommendPromptName,
isMarkdown,
renderedMarkdown, recommendPromptReason,
selectedScene,
setSelectedScene,
showSpeaker,
isEditingTranscription,
editableText,
enterEditMode,
exitEditMode,
};
},
delimiters: ["${", "}"],
});
//--------------------模板暴露与事件出口 end------------------
//--------------------Vue 应用主体定义 end------------------
//------------------- Vue 全局注入与挂载 start------------------
// Add t function as a global property BEFORE mounting so it's available in templates immediately
app.config.globalProperties.t = safeT;
// Also add tc for pluralization
app.config.globalProperties.tc = (key, count, params = {}) => {
if (!window.i18n || !window.i18n.tc) {
return key;
}
return window.i18n.tc(key, count, params);
};
// Provide t and tc to the app
app.provide("t", safeT);
app.provide("tc", (key, count, params = {}) => {
if (!window.i18n || !window.i18n.tc) {
return key;
}
return window.i18n.tc(key, count, params);
});
// Mount the app
app.mount("#app");
// Hide loading overlay after Vue is mounted and ready
Vue.nextTick(() => {
const overlay = document.querySelector(".app-loading-overlay");
if (overlay) {
// Small delay to ensure everything is rendered
setTimeout(() => {
overlay.classList.add("fade-out");
setTimeout(() => {
overlay.remove();
document.body.classList.remove("app-loading");
}, 300);
}, 100);
} else {
document.body.classList.remove("app-loading");
}
});
//--------------------Vue 全局注入与挂载 end------------------
});