4762 lines
245 KiB
HTML
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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ title }} - {{ html_title }}</title>
<link rel="icon" href="/tool/speakr/static/img/favicon.ico" type="image/svg+xml">
<!-- All dependencies bundled locally for offline support -->
<script src="/tool/speakr/static/vendor/js/tailwind.min.js"></script>
<!-- All dependencies bundled locally for offline support -->
<link rel="stylesheet" href="/tool/speakr/static/vendor/css/fontawesome.min.css">
<link rel="stylesheet" href="/tool/speakr/static/css/styles.css">
<script src="/tool/speakr/static/js/i18n.js"></script>
<!-- Loading overlay to prevent FOUC -->
{% include 'includes/loading_overlay.html' %}
<script src="/tool/speakr/static/js/loading.js"></script>
<script>
// Initialize i18n
let t = (key) => key; // Default fallback
async function initializeI18n(language) {
const userLanguage = language || "{{ user_language }}";
// Initialize the global i18n instance
if (window.i18n) {
await window.i18n.init(userLanguage);
// Create a shorthand translation function
t = (key, params) => window.i18n.t(key, params);
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
}
}
// Function to update all UI elements after language change
function updateAllUIElements() {
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
// Update language options in dropdown to show native names
const langSelect = document.getElementById('ui_language');
if (langSelect) {
const langOptions = {
'en': 'English',
'es': 'Español (Spanish)',
'fr': 'Français (French)',
'zh': '中文 (Chinese)',
'de': 'Deutsch (German)'
};
Array.from(langSelect.options).forEach(option => {
if (langOptions[option.value]) {
option.textContent = langOptions[option.value];
}
});
}
// Force update any Vue or dynamic content by dispatching a custom event
window.dispatchEvent(new CustomEvent('language-changed', { detail: { language: window.i18n.currentLanguage } }));
}
// Function to change language immediately
async function changeLanguageImmediately(newLanguage) {
try {
// Show loading indicator
const uiLanguageSelect = document.getElementById('ui_language');
uiLanguageSelect.disabled = true;
// Add a loading message
const originalText = uiLanguageSelect.parentElement.querySelector('p').textContent;
const loadingMsg = uiLanguageSelect.parentElement.querySelector('p');
if (loadingMsg) {
loadingMsg.textContent = 'Applying language change...';
}
// Get CSRF token
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
// Create form data with just the language change
const formData = new FormData();
formData.append('csrf_token', csrfToken);
formData.append('user_name', document.getElementById('user_name').value || '');
formData.append('user_job_title', document.getElementById('user_job_title').value || '');
formData.append('user_company', document.getElementById('user_company').value || '');
formData.append('ui_language', newLanguage);
formData.append('transcription_language', document.getElementById('transcription_language').value || '');
formData.append('output_language', document.getElementById('output_language').value || '');
// Send AJAX request to update language preference
const response = await fetch('/tool/speakr/account', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrfToken
},
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Store the new language in localStorage for other pages
localStorage.setItem('preferredLanguage', newLanguage);
localStorage.setItem('ui_language_changed', 'true');
// Store current tab to restore after reload
const activeTab = document.querySelector('.tab-link.border-\\[var\\(--border-accent\\)\\]');
if (activeTab) {
const tabName = activeTab.getAttribute('onclick')?.match(/show(\w+)Tab/)?.[1]?.toLowerCase();
if (tabName) {
localStorage.setItem('activeAccountTab', tabName);
}
}
// Reload the page to apply the new language completely
// This ensures all server-side rendered content is also updated
window.location.reload();
} catch (error) {
console.error('Error updating language:', error);
alert('Failed to update language. Please try again.');
// Revert the dropdown to previous value if update failed
document.getElementById('ui_language').value = currentUILanguage;
document.getElementById('ui_language').disabled = false;
// Restore original text if loading message was shown
const loadingMsg = document.getElementById('ui_language').parentElement.querySelector('p');
if (loadingMsg) {
loadingMsg.textContent = originalText;
}
}
}
// Function to apply the theme based on localStorage
function applyTheme() {
// Guard against early execution
if (!document.documentElement) return;
// Apply dark mode
const savedMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Apply color scheme
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
// Remove all other theme classes
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
// Add the correct theme class
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
<style>
/* Make the page take full height */
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
/* Container layout for full height */
.main-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Tab content area with fixed height and scrolling */
.tab-content-wrapper {
height: calc(100vh - 280px);
/* Adjust based on header and tab nav height */
overflow-y: auto;
overflow-x: hidden;
}
@media (max-width: 640px) {
.tab-content-wrapper {
height: calc(100vh - 320px);
/* Slightly less height on mobile */
}
}
/* Ensure all tab content divs take full height */
.tab-content {
min-height: 100%;
display: flex;
flex-direction: column;
}
/* Custom scrollbar for content areas */
.tab-content-wrapper::-webkit-scrollbar {
width: 8px;
}
.tab-content-wrapper::-webkit-scrollbar-track {
background: transparent;
}
.tab-content-wrapper::-webkit-scrollbar-thumb {
background-color: var(--border-secondary);
border-radius: 4px;
}
.tab-content-wrapper::-webkit-scrollbar-thumb:hover {
background-color: var(--border-accent);
}
/* Horizontal scrollbar for tab navigation */
.tab-nav {
scrollbar-width: thin;
scrollbar-color: var(--border-secondary) transparent;
}
.tab-nav::-webkit-scrollbar {
height: 6px;
}
.tab-nav::-webkit-scrollbar-track {
background: transparent;
}
.tab-nav::-webkit-scrollbar-thumb {
background-color: var(--border-secondary);
border-radius: 3px;
}
.tab-nav::-webkit-scrollbar-thumb:hover {
background-color: var(--border-accent);
}
/* Custom scrollbar styling for Speakers Management */
#content-speakers .overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
#content-speakers .overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
#content-speakers .overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-secondary);
border-radius: 4px;
transition: background-color 0.2s;
}
#content-speakers .overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--border-accent);
}
/* Ensure the content fits properly */
#content-speakers {
container-type: inline-size;
}
/* Responsive grid adjustments */
@container (max-width: 640px) {
#speakersTabGrid {
grid-template-columns: 1fr;
}
}
@container (min-width: 641px) and (max-width: 768px) {
#speakersTabGrid {
grid-template-columns: repeat(2, 1fr);
}
}
@container (min-width: 769px) and (max-width: 1024px) {
#speakersTabGrid {
grid-template-columns: repeat(3, 1fr);
}
}
@container (min-width: 1025px) {
#speakersTabGrid {
grid-template-columns: repeat(4, 1fr);
}
}
/* Hide scrollbar for tabs */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="main-container container mx-auto px-4 sm:px-6 lg:px-8 py-6">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="/tool/speakr" class="flex items-center">
<img src="/tool/speakr/static/img/icon-192x192.png" alt="{{ html_title }} Logo" class="h-9 w-9 mr-2">
{{ html_title }}
</a>
</h1>
<div class="flex items-center space-x-2">
<button id="darkModeToggle"
class="p-2 rounded-full text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] dark:text-gray-400 dark:hover:bg-gray-700 transition-colors duration-200">
<i id="darkModeIcon" class="fas fa-moon"></i>
</button>
<div class="relative" id="userDropdown">
<button id="userDropdownButton"
class="flex items-center px-3 py-2 border border-[var(--border-secondary)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-accent)] focus:outline-none">
<i class="fas fa-user mr-2"></i>
<span>{{ current_user.username }}</span>
<i class="fas fa-chevron-down ml-2"></i>
</button>
<div id="userDropdownMenu"
class="hidden absolute right-0 mt-2 w-48 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-10">
<a href="/tool/speakr"
class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)]">
<i class="fas fa-home mr-2"></i> <span data-i18n="nav.home">Home</span>
</a>
<a href="/tool/speakr/account"
class="block px-4 py-2 text-[var(--text-accent)] bg-[var(--bg-accent)]">
<i class="fas fa-user-circle mr-2"></i> <span data-i18n="nav.account">Account</span>
</a>
{% if current_user.is_admin %}
<a href="/tool/speakr/admin"
class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)]">
<i class="fas fa-user-shield mr-2"></i> <span data-i18n="nav.admin">Admin</span>
</a>
{% endif %}
<a href="/tool/speakr/logout"
class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-danger)]">
<i class="fas fa-sign-out-alt mr-2"></i> <span data-i18n="nav.signOut">Logout</span>
</a>
</div>
</div>
</div>
</header>
<main class="flex-grow flex flex-col">
<div
class="bg-[var(--bg-secondary)] rounded-xl shadow-lg border border-[var(--border-primary)] flex flex-col flex-grow overflow-hidden">
<div class="border-b border-[var(--border-primary)]">
<div class="relative">
<!-- Scroll gradient indicators -->
<div class="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-[var(--bg-secondary)] to-transparent z-[1] pointer-events-none opacity-0 transition-opacity duration-200 flex items-center justify-start"
id="left-scroll-indicator">
<i class="fas fa-chevron-left text-[var(--text-muted)] ml-1"></i>
</div>
<div class="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-[var(--bg-secondary)] to-transparent z-[1] pointer-events-none opacity-100 transition-opacity duration-200 flex items-center justify-end"
id="right-scroll-indicator">
<i class="fas fa-chevron-right text-[var(--text-muted)] mr-1"></i>
</div>
<nav class="tab-nav px-1 sm:px-4 lg:px-6 pt-3 sm:pt-4 lg:pt-6 pb-4 sm:pb-6 -mb-px overflow-x-auto scrollbar-hide"
aria-label="Tabs" id="tabs-container">
<div class="inline-flex gap-1 sm:gap-2">
<a href="#" id="tab-account"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-[var(--border-accent)] text-[var(--text-accent)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-user-circle mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
data-i18n="account.title">Account</span>
</a>
<a href="#" id="tab-prompts"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-cog mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
data-i18n="accountTabs.promptOptions">Prompts</span>
</a>
<a href="#" id="tab-shares"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-share-alt mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
data-i18n="accountTabs.sharedTranscripts">Shares</span>
</a>
<a href="#" id="tab-speakers"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-users mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
data-i18n="accountTabs.speakersManagement">Speakers</span>
</a>
<a href="#" id="tab-tags"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-tags mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
data-i18n="accountTabs.tagManagement">Tags</span>
</a>
<a href="#" id="tab-voiceprint"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-tags mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>声纹</span>
</a>
<a href="#" id="tab-templates"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-file-alt mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
data-i18n="accountTabs.transcriptTemplates">Templates</span>
</a>
<a href="#" id="tab-about"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-info-circle mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
data-i18n="accountTabs.about">About</span>
</a>
</div>
</nav>
</div>
</div>
<div class="tab-content-wrapper px-4 sm:px-6 lg:px-8 pb-4 sm:pb-6 lg:pb-8">
<div id="content-account" class="tab-content pt-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6" data-i18n="account.title">
Account Information</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div
class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left Column: Form -->
<div>
<form id="accountInfoForm" method="POST" action="/tool/speakr/account"
class="space-y-6">
<div class="mb-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
data-i18n="account.personalInfo">Personal Information</h3>
<div
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
<div>
<label for="user_name"
class="block text-sm font-medium text-[var(--text-muted)]"
data-i18n="account.fullName">Full Name</label>
<input type="text" name="user_name" id="user_name"
value="{{ current_user.name or '' }}"
data-i18n-placeholder="form.yourFullName"
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div>
<label for="user_job_title"
class="block text-sm font-medium text-[var(--text-muted)]"
data-i18n="account.jobTitle">Job Title</label>
<input type="text" name="user_job_title" id="user_job_title"
value="{{ current_user.job_title or '' }}"
placeholder="e.g., Software Engineer"
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div>
<label for="user_company"
class="block text-sm font-medium text-[var(--text-muted)]"
data-i18n="account.companyOrganization">Company /
Organization</label>
<input type="text" name="user_company" id="user_company"
value="{{ current_user.company or '' }}"
placeholder="e.g., Acme Corp"
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
</div>
</div>
<div class="mb-6">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
data-i18n="account.languagePreferences">Language Preferences</h3>
<div
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
<div>
<label for="ui_language"
class="block text-sm font-medium text-[var(--text-muted)]"
data-i18n="account.interfaceLanguage">Interface Language</label>
<select name="ui_language" id="ui_language"
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="en" {% if current_user.ui_language=='en' or not
current_user.ui_language %}selected{% endif %}>English</option>
<option value="zh" {% if current_user.ui_language=='zh' %}selected{%
endif %}>中文 (Chinese)</option>
<option value="de" {% if current_user.ui_language=='de' %}selected{%
endif %}>Deutsch (German)</option>
<option value="es" {% if current_user.ui_language=='es' %}selected{%
endif %}>Español (Spanish)</option>
<option value="fr" {% if current_user.ui_language=='fr' %}selected{%
endif %}>Français (French)</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1"
data-i18n="account.chooseLanguageForInterface">Choose the language
for the application interface.</p>
</div>
<div>
<label for="transcription_language"
class="block text-sm font-medium text-[var(--text-muted)]"
data-i18n="account.transcriptionLanguage">转录语言</label>
<input type="text" name="transcription_language"
id="transcription_language"
value="{{ current_user.transcription_language or '' }}"
placeholder="e.g., en, es, zh (ISO 639-1 code)"
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">
<p class="text-xs text-[var(--text-light)] mt-1"
data-i18n="account.leaveBlankForAutoDetect">Leave blank for
auto-detection by transcription service.</p>
</div>
<div>
<label for="output_language"
class="block text-sm font-medium text-[var(--text-muted)]"
data-i18n="account.preferredOutputLanguage">Preferred Output
Language</label>
<input type="text" name="output_language" id="output_language"
value="{{ current_user.output_language or '' }}"
placeholder="e.g., English, Spanish, Chinese"
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">
<p class="text-xs text-[var(--text-light)] mt-1"
data-i18n="account.languageForSummaries">Language for titles,
summaries, and chat. Leave blank for default (default behavior of
your chosen model).</p>
</div>
</div>
</div>
{% if current_user.is_admin %}
<div class="mb-6">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2">热词配置</h3>
<div
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
<div>
<label for="transcription_language"
class="block text-sm font-medium text-[var(--text-muted)]">热词</label>
<textarea name="hot-textarea"
style="height: 120px;margin-top: 10px;border-radius: 0.375rem;"
class="hot-textarea w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"></textarea>
<!-- <input type="text" name="transcription_language" id="transcription_language" value="{{ current_user.transcription_language or '' }}" placeholder="请输入热词" class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]"> -->
<p class="text-xs text-[var(--text-light)] mt-1">一行一个,权重用空格隔开,如 "阿里巴巴 20
</p>
</div>
</div>
</div>
<div class="mb-6">
<div class="flex items-center justify-between gap-3 mb-2 flex-wrap">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="text-lg font-medium text-[var(--text-secondary)]">标准词映射</h3>
<span id="standardWordMappingsCount"
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-primary)]">
0 条
</span>
</div>
<div class="flex items-center gap-2 flex-wrap">
<button type="button" id="toggleStandardWordImportPanel"
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors duration-200">
<i class="fas fa-file-import mr-2"></i>批量导入
</button>
<button type="button" id="copyStandardWordMappingsText"
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors duration-200">
<i class="fas fa-copy mr-2"></i>复制文本
</button>
<button type="button" id="clearAllStandardWordMappings"
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--text-danger)] text-[var(--text-danger)] hover:bg-[var(--bg-danger)] hover:text-[var(--text-button)] transition-colors duration-200">
<i class="fas fa-trash-alt mr-2"></i>批量删除
</button>
<button type="button" id="addStandardWordMappingRow"
class="inline-flex items-center px-3 py-2 text-sm rounded-md bg-[var(--bg-button)] text-[var(--text-button)] hover:bg-[var(--bg-button-hover)] transition-colors duration-200">
<i class="fas fa-plus mr-2"></i>新增映射
</button>
</div>
</div>
<div
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
<div id="standardWordImportPanel"
class="hidden rounded-lg border border-dashed border-[var(--border-primary)] bg-[var(--bg-secondary)] p-4 space-y-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<p class="text-sm font-medium text-[var(--text-secondary)]">批量导入映射</p>
<p class="text-xs text-[var(--text-light)] mt-1">支持每行一个映射格式标准词常用词1、常用词2</p>
</div>
<button type="button" id="closeStandardWordImportPanel"
class="inline-flex items-center px-2.5 py-1.5 text-xs rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200">
收起
</button>
</div>
<textarea id="standardWordMappingsBulkInput"
style="height: 120px;border-radius: 0.375rem;"
class="w-full p-3 border border-[var(--border-primary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)] resize-y"
placeholder="例如:&#10;OpenAI欧喷AI、欧彭AI&#10;Whisper威士博 威士吧"></textarea>
<div class="flex items-center gap-2 flex-wrap">
<button type="button" id="replaceStandardWordMappingsFromBulk"
class="inline-flex items-center px-3 py-2 text-sm rounded-md bg-[var(--bg-button)] text-[var(--text-button)] hover:bg-[var(--bg-button-hover)] transition-colors duration-200">
覆盖导入
</button>
<button type="button" id="appendStandardWordMappingsFromBulk"
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200">
追加导入
</button>
<button type="button" id="clearStandardWordMappingsBulkInput"
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200">
清空
</button>
</div>
</div>
<div
class="hidden md:grid grid-cols-[110px_minmax(0,1fr)_minmax(0,2fr)_150px] gap-3 px-4 py-2 rounded-md border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-xs font-medium uppercase tracking-wide text-[var(--text-light)]">
<div>排序</div>
<div>标准词</div>
<div>常用词</div>
<div>操作</div>
</div>
<div id="standardWordMappingsList" class="space-y-2"></div>
<div id="standardWordMappingsEmpty"
class="text-sm text-[var(--text-light)] bg-[var(--bg-secondary)] border border-dashed border-[var(--border-primary)] rounded-md px-4 py-3">
暂无映射。点击“新增映射”后,填写标准词和对应的常用词。
</div>
<p class="text-xs text-[var(--text-light)]">
一个标准词可对应多个常用词常用词之间可使用顿号“、”或空格分隔。例如OpenAI → 欧喷AI、欧彭AI
</p>
</div>
</div>
<div class="mb-6">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2">换行配置</h3>
<div
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
<div>
<label for="transcription_language"
class="block text-sm font-medium text-[var(--text-muted)]">关键词</label>
<textarea name="handle-textarea"
style="height: 120px;margin-top: 10px;border-radius: 0.375rem;"
class="handle-textarea w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"></textarea>
<!-- <input type="text" name="transcription_language" id="transcription_language" value="{{ current_user.transcription_language or '' }}" placeholder="请输入热词" class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]"> -->
<p class="text-xs text-[var(--text-light)] mt-1">
关键词使用逗号隔开,例如“报告完毕,发言完毕,.*席报告”
</p>
</div>
</div>
</div>
{% endif %}
<div class="mb-6">
<h3
class="text-lg font-medium text-[var(--text-secondary)] mb-2 flex items-center gap-3">
提示词
<button id="openPromptModal"
type="button"
class="px-2 py-1 text-sm bg-blue-500 text-white rounded">选择提示词</button>
</h3>
<div
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
<div>
<label for="transcription_language"
class="block text-sm font-medium text-[var(--text-muted)]">提示词</label>
<textarea name="handle-prompt" id="promptTextarea"
readonly
style="height: 120px;margin-top: 10px;border-radius: 0.375rem;"
class="handle-prompt w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"></textarea>
<p class="text-xs text-[var(--text-light)] mt-1">
点击上方按钮选择提示词,如果不选择使用默认提示词。</p>
</div>
</div>
</div>
<!-- 弹框 Modal -->
<div id="promptModal"
class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex justify-center items-center">
<div
class="bg-[#1f2937] text-white w-1/2 p-6 rounded-lg shadow-xl border border-gray-600">
<h3 class="text-xl font-semibold mb-4">选择提示词</h3>
<div class="bg-[#111827] p-3 rounded max-h-72 overflow-y-auto text-white">
<!-- 表头 -->
<div class="grid grid-cols-[50px_350px] px-2 py-2 border-b border-gray-600 text-sm font-semibold">
<div class="text-center">选择</div>
<div>提示词</div>
</div>
<!-- 表体 -->
<div id="promptList" class="space-y-2 mt-2"></div>
</div>
<div class="flex justify-end mt-4 gap-3">
<button id="cancelPrompt" type="button"
class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600">取消</button>
<button id="confirmPrompt" type="button"
class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-700">确定</button>
</div>
</div>
</div>
<button type="submit"
class="w-full inline-flex items-center justify-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-save mr-2"></i> <span
data-i18n="account.saveAllPreferences">Save All Preferences</span>
</button>
</form>
</div>
<!-- Right Column: Stats, User Details, Account Actions -->
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
data-i18n="account.statistics">Account Statistics</h3>
<div
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<div class="grid grid-cols-2 gap-4">
<div
class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-3xl font-bold text-[var(--text-accent)]">{{
current_user.recordings|length }}</span>
<span class="block text-sm text-[var(--text-muted)]"
data-i18n="account.totalRecordings">Total Recordings</span>
</div>
<div
class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-3xl font-bold text-[var(--text-accent)]">
{% set completed_count =
current_user.recordings|selectattr('status', 'equalto',
'COMPLETED')|list|length %}
{{ completed_count }}
</span>
<span class="block text-sm text-[var(--text-muted)]"
data-i18n="account.completedRecordings">Completed</span>
</div>
<div
class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-3xl font-bold text-[var(--text-warn-strong)]">
{% set processing_count =
current_user.recordings|selectattr('status', 'in', ['PENDING',
'PROCESSING', 'SUMMARIZING'])|list|length %}
{{ processing_count }}
</span>
<span class="block text-sm text-[var(--text-muted)]"
data-i18n="account.processingRecordings">Processing</span>
</div>
<div
class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-3xl font-bold text-[var(--text-danger)]">
{% set failed_count = current_user.recordings|selectattr('status',
'equalto', 'FAILED')|list|length %}
{{ failed_count }}
</span>
<span class="block text-sm text-[var(--text-muted)]"
data-i18n="account.failedRecordings">Failed</span>
</div>
</div>
</div>
</div>
<div class="mt-6"> <!-- Added mt-6 for spacing, was mb-6 on User Details -->
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
data-i18n="account.userDetails">User Details</h3>
<div
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<div class="mb-3">
<span class="block text-sm font-medium text-[var(--text-muted)]"
data-i18n="account.username">Username</span>
<span class="block text-[var(--text-primary)]">{{ current_user.username
}}</span>
</div>
<div class="mb-3">
<span
class="block text-sm font-medium text-[var(--text-muted)]">Email</span>
<span class="block text-[var(--text-primary)]">{{ current_user.email
}}</span>
</div>
</div>
</div>
<div class="mt-6"> <!-- Added mt-6 for spacing, was mt-8 on Account Actions -->
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
data-i18n="account.accountActions">Account Actions</h3>
<div class="flex flex-col space-y-3">
<a href="/tool/speakr"
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-microphone mr-2"></i> <span
data-i18n="account.goToRecordings">Go to Recordings</span>
</a>
<button id="changePasswordBtn"
class="inline-flex items-center px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-key mr-2"></i> <span
data-i18n="account.changePassword">Change Password</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div id="content-prompts" class="hidden tab-content pt-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6"
data-i18n="accountTabs.promptOptions">Prompt Options</h2>
<form id="customPromptsForm" method="POST" action="/tool/speakr/account" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<!-- Event Extraction Settings -->
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-4">
<i class="fas fa-calendar-alt mr-2"></i>
<span data-i18n="eventExtraction.title">Event Extraction</span>
</h3>
<div class="mb-4">
<label class="flex items-start space-x-3">
<input type="checkbox" name="extract_events" id="extract_events" {% if
current_user.extract_events %}checked{% endif %}
class="mt-1 h-4 w-4 text-[var(--border-accent)] focus:ring-[var(--border-focus)] border-[var(--border-secondary)] rounded">
<div>
<span class="block text-sm font-medium text-[var(--text-primary)]"
data-i18n="eventExtraction.enableLabel">
Enable automatic event extraction from transcripts
</span>
<span class="block text-xs text-[var(--text-muted)] mt-1"
data-i18n="eventExtraction.description">
When enabled, the AI will identify meetings, appointments, and deadlines
mentioned in your recordings and create downloadable calendar events.
</span>
</div>
</label>
</div>
<div
class="mt-4 p-3 bg-[var(--bg-info-light)] text-[var(--text-info-strong)] rounded-lg text-sm">
<i class="fas fa-info-circle mr-1"></i>
<span data-i18n="eventExtraction.info">
Extracted events will appear in a new "Events" tab on recordings where calendar
items are detected.
</span>
</div>
</div>
<!-- Custom Prompts Section -->
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-4"
data-i18n="customPrompts.summaryGeneration">Summary Generation Prompt</h3>
<div class="mb-4">
<label for="summary_prompt_custom"
class="block text-sm font-medium text-[var(--text-muted)] mb-2">
<span data-i18n="customPrompts.yourCustomPrompt">Your Custom Summary
Prompt</span>
</label>
<textarea name="summary_prompt" id="summary_prompt_custom" rows="8"
placeholder="Describe how you want your summaries structured. Leave blank to use the admin's default prompt."
data-i18n-placeholder="customPrompts.promptPlaceholder"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">{{ current_user.summary_prompt or '' }}</textarea>
<p class="text-xs text-[var(--text-light)] mt-2">
<span data-i18n="customPrompts.promptDescription">This prompt will be used to
generate summaries for your transcriptions. It overrides the admin's default
prompt.</span>
</p>
</div>
<div
class="mt-6 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
<h4 class="text-sm font-semibold text-[var(--text-accent)] mb-2">
<i class="fas fa-info-circle mr-1"></i>
<span data-i18n="customPrompts.currentDefaultPrompt">Current Default Prompt
(Used if you leave the above blank)</span>
</h4>
<div
class="bg-[var(--bg-primary)] p-3 rounded border border-[var(--border-primary)]">
<pre
class="text-xs text-[var(--text-muted)] whitespace-pre-wrap">{{ default_summary_prompt_text }}</pre>
</div>
</div>
<div class="mt-4">
<details class="cursor-pointer">
<summary
class="text-sm text-[var(--text-accent)] hover:text-[var(--text-accent-hover)]">
<i class="fas fa-question-circle mr-1"></i>
<span data-i18n="customPrompts.tipsTitle">Tips for Writing Effective
Prompts</span>
</summary>
<div
class="mt-3 p-3 bg-[var(--bg-info-light)] text-[var(--text-info-strong)] rounded-lg text-sm">
<ul class="list-disc list-inside space-y-1">
<li data-i18n="customPrompts.tip1">Be specific about the sections you
want in your summary</li>
<li data-i18n="customPrompts.tip2">Use clear formatting instructions
(e.g., "Use bullet points", "Create numbered lists")</li>
<li data-i18n="customPrompts.tip3">Specify if you want certain
information prioritized</li>
<li data-i18n="customPrompts.tip4">The system will automatically provide
the transcript content to the AI</li>
<li data-i18n="customPrompts.tip5">Your output language preference (if
set) will be applied automatically</li>
</ul>
</div>
</details>
</div>
</div>
<div class="flex justify-end">
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-save mr-2"></i> <span data-i18n="buttons.saveSettings">Save
Settings</span>
</button>
</div>
</form>
</div>
<div id="content-shares" class="hidden tab-content pt-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6"
data-i18n="accountTabs.sharedTranscripts">My Shared Transcripts</h2>
<div id="shares-container" class="space-y-4">
<!-- Shares will be loaded here by JavaScript -->
</div>
</div>
<div id="content-speakers" class="hidden tab-content pt-6">
<div class="flex-shrink-0">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4"
data-i18n="accountTabs.speakersManagement">Speakers Management</h2>
<p class="text-[var(--text-secondary)] mb-4" data-i18n="speakersManagement.description">
Manage your saved speakers. These are automatically saved when you use speaker names in
your recordings.</p>
</div>
<div class="flex-1 overflow-y-auto min-h-0 pr-2"
style="scrollbar-width: thin; scrollbar-color: var(--border-secondary) transparent;">
<!-- Loading state -->
<div id="speakersTabLoading" class="text-center py-8 hidden">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="text-[var(--text-muted)] mt-2" data-i18n="speakersManagement.loadingSpeakers">
Loading speakers...</p>
</div>
<!-- Error state -->
<div id="speakersTabError"
class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span id="speakersTabErrorMessage" data-i18n="speakersManagement.failedToLoad">Failed to
load speakers</span>
</div>
<!-- Empty state -->
<div id="speakersTabEmpty" class="text-center py-12 hidden">
<i class="fas fa-users text-4xl text-[var(--text-light)] mb-4"></i>
<p class="text-[var(--text-muted)] text-lg mb-2"
data-i18n="speakersManagement.noSpeakersYet">No speakers saved yet</p>
<p class="text-sm text-[var(--text-light)]"
data-i18n="speakersManagement.speakersWillAppear">Speakers will appear here when you
use speaker names in your recordings</p>
</div>
<!-- Speakers grid with scrollable container -->
<div id="speakersTabGrid"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
<!-- Speakers will be populated here -->
</div>
</div>
<!-- Fixed footer with count and actions -->
<div
class="flex-shrink-0 flex justify-between items-center mt-4 pt-4 border-t border-[var(--border-primary)]">
<div class="text-sm text-[var(--text-muted)]">
<span id="speakersTabCount">0</span> <span
data-i18n="speakersManagement.totalSpeakers">speakers saved</span>
</div>
<div class="flex space-x-3">
<button id="deleteAllSpeakersTabBtn"
class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
<i class="fas fa-trash mr-2"></i> <span data-i18n="buttons.deleteAll">Delete
All</span>
</button>
</div>
</div>
</div>
<div id="content-tags" class="hidden tab-content pt-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6"
data-i18n="accountTabs.tagManagement">Tag Management</h2>
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<p class="text-[var(--text-secondary)]" data-i18n="tagManagement.description">Organize
your recordings with custom tags. Each tag can have its own summary prompt and
default ASR settings.</p>
<button id="createTagBtn"
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-plus mr-2"></i> <span data-i18n="tagManagement.createTag">Create
Tag</span>
</button>
</div>
<!-- Loading state -->
<div id="tagsLoading" class="text-center py-8 hidden">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="text-[var(--text-muted)] mt-2">Loading tags...</p>
</div>
<!-- Error state -->
<div id="tagsError"
class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span id="tagsErrorMessage">Failed to load tags</span>
</div>
<!-- Empty state -->
<div id="tagsEmpty" class="text-center py-12 hidden">
<i class="fas fa-tags text-4xl text-[var(--text-light)] mb-4"></i>
<p class="text-[var(--text-muted)] text-lg mb-2">尚未创建任何标签</p>
<p class="text-sm text-[var(--text-light)]">创建您的第一个标签,以便整理录音文件</p>
</div>
<!-- Tags grid -->
<div id="tagsGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Tags will be populated here -->
</div>
</div>
</div>
<div id="content-voiceprint" class="hidden tab-content">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6"
style="color: #fff;line-height: 1.5rem;margin: 20px 0 0 0;">声纹管理</h2>
<div id="content-voiceprint">
<div class="top-bar">
<button class="btn btn-primary" style="margin-right: 10px;" onclick="synchronize()">同步至声纹平台</button>
<button class="btn btn-primary" onclick="openModal('add')">添加</button>
</div>
<table id="voiceList">
<thead>
<tr>
<th>名称</th>
<th>声纹编码</th>
<!-- <th>创建时间</th> -->
<th>操作</th>
</tr>
</thead>
<tbody>
<!-- 动态渲染数据 -->
</tbody>
</table>
</div>
<div id="message"
style="z-index:9999;position:fixed;top:20px;right:20px;padding:10px 20px;background:#4caf50;color:white;border-radius:5px;display:none;">
</div>
<!-- 添加/编辑弹框 -->
<div class="modal" id="voiceModal">
<div class="modal-content">
<div class="modal-header" id="modalTitle">添加说话人</div>
<div class="label-tit">
<span style="color: #dc2626;margin-right: 5px;">*</span>
声纹向量
</div>
<div class="file-box">
<input type="file" id="voiceFile" accept="audio/*">
<button type="button" class="btn-clear" onclick="clearUploadFile()"
style="width: 100px;position: relative;top: -5px;">清空文件</button>
</div>
<div class="record-box">
<button type="button" onclick="startRecording(this)">🎤 录音</button>
<button type="button" class="btn-clear" onclick="clearRecording()">清空录音</button>
</div>
<audio id="audioPreview" controls style="display:none;"></audio>
<p class="tips">请选择一段清晰的独白语音(2秒以上),不要包含背景噪音和其他人的声音</p>
<div class="label-tit">
<span style="color: #dc2626;margin-right: 5px;">*</span>
名称
</div>
<input type="text" id="voiceName" placeholder="名称 (必填)" style="background-color: #fff;">
<!-- <div class="label-tit">描述</div>
<textarea id="voiceDesc" placeholder="描述"
style="background-color: #fff;border-radius: 4px !important;"></textarea> -->
<div class="modal-footer">
<button class="btn" onclick="closeModal()">取消</button>
<button id="saveVoiceBtn" class="btn btn-primary" onclick="saveVoice()"
style="color: #fff;">保存</button>
</div>
</div>
</div>
<!-- 删除确认弹框 -->
<div class="modal" id="confirmDeleteModal">
<div class="modal-content">
<div class="modal-header" style="color: #000;">确认删除?</div>
<div class="modal-footer">
<button class="btn" onclick="closeDeleteModal()" style="border-color: #409eff;
color: #409eff;">取消</button>
<button class="btn btn-danger" onclick="confirmDelete()"
style="color: #fff;">删除</button>
</div>
</div>
</div>
</div>
<div id="content-templates" class="hidden tab-content pt-6">
<div class="space-y-6">
<!-- Templates Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)]"
data-i18n="transcriptTemplates.title">Transcript Templates</h2>
<p class="text-[var(--text-secondary)]" data-i18n="transcriptTemplates.description">
Customize how transcripts are formatted for download and export.</p>
</div>
<button id="createTemplateBtn"
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-plus mr-2"></i>
<span data-i18n="transcriptTemplates.createNew">Create Template</span>
</button>
</div>
<!-- Templates Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Templates List (Left) -->
<div class="lg:col-span-1">
<div
class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-4">
<h3 class="text-sm font-medium text-[var(--text-secondary)] mb-3"
data-i18n="transcriptTemplates.availableTemplates">Available Templates</h3>
<div id="templatesList" class="space-y-2">
<!-- Templates will be populated here -->
</div>
<button id="createDefaultTemplatesBtn"
class="mt-4 w-full px-3 py-2 text-sm bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-accent-light)] transition-colors">
<i class="fas fa-magic mr-2"></i>
<span data-i18n="transcriptTemplates.createDefaults">Create Default
Templates</span>
</button>
</div>
</div>
<!-- Template Editor (Right) -->
<div class="lg:col-span-2">
<div id="templateEditor"
class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-6 hidden">
<form id="templateForm" class="space-y-4">
<div>
<label
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="transcriptTemplates.templateName">Template Name</label>
<input type="text" id="templateName"
class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]"
required>
</div>
<div>
<label
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="transcriptTemplates.description">Description</label>
<input type="text" id="templateDescription"
class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
</div>
<div>
<label
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="transcriptTemplates.template">Template</label>
<textarea id="templateContent" rows="6"
class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] font-mono text-sm"
required></textarea>
<!-- Available Variables -->
<div class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md">
<p class="text-xs font-medium text-[var(--text-secondary)] mb-2"
data-i18n="transcriptTemplates.availableVars">Available
Variables:</p>
<div class="flex flex-wrap gap-2">
<code
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{index}}</code>
<code
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{speaker}}</code>
<code
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{text}}</code>
<code
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{start_time}}</code>
<code
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{end_time}}</code>
</div>
<p class="text-xs text-[var(--text-muted)] mt-2"
data-i18n="transcriptTemplates.filters">Filters: |upper for
uppercase, |srt for subtitle time format</p>
</div>
</div>
<div class="flex items-center">
<input type="checkbox" id="templateIsDefault" class="mr-2">
<label for="templateIsDefault"
class="text-sm text-[var(--text-secondary)]"
data-i18n="transcriptTemplates.setDefault">Set as default
template</label>
</div>
<div class="flex gap-3">
<button type="submit"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-save mr-2"></i>
<span data-i18n="transcriptTemplates.save">保存</span>
</button>
<button type="button" id="cancelTemplateBtn"
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-secondary)]">
<span data-i18n="transcriptTemplates.cancel">取消</span>
</button>
<button type="button" id="deleteTemplateBtn"
class="px-4 py-2 bg-[var(--bg-danger)] text-white rounded-md hover:bg-[var(--bg-danger-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200 hidden">
<i class="fas fa-trash mr-2"></i>
<span data-i18n="transcriptTemplates.delete">Delete</span>
</button>
</div>
</form>
</div>
<!-- Empty State -->
<div id="templateEmptyState"
class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-12 text-center">
<i class="fas fa-file-alt text-4xl text-[var(--text-muted)] mb-4"></i>
<p class="text-[var(--text-muted)]"
data-i18n="transcriptTemplates.selectOrCreate">Select a template to edit or
create a new one</p>
</div>
</div>
</div>
<!-- Documentation Link -->
<div class="mt-8 text-center">
<a href="https://murtaza-nasir.github.io/speakr/user-guide/transcript-templates/"
target="_blank"
class="text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors">
<i class="fas fa-question-circle mr-1"></i>
<span data-i18n="transcriptTemplates.viewGuide">View Template Guide</span>
</a>
</div>
</div>
</div>
<div id="content-about" class="hidden tab-content pt-6">
<div class="space-y-8">
<!-- Header Section -->
<div class="text-center">
<div class="flex items-center justify-center mb-4">
<img src="/tool/speakr/static/img/icon-192x192.png" alt="{{ html_title }} Logo"
class="h-16 w-16 mr-4">
<div>
<h2 class="text-3xl font-bold text-[var(--text-primary)]">{{ html_title }}</h2>
<p class="text-[var(--text-secondary)]" data-i18n="aboutPage.tagline">AI-Powered
Audio Transcription & Note-Taking</p>
</div>
</div>
<div
class="inline-flex items-center px-3 py-1 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-full text-sm font-medium">
<i class="fas fa-tag mr-2"></i>
Version <span id="system-version" class="ml-1 font-mono">Loading...</span>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- System Information -->
<div
class="bg-[var(--bg-secondary)] rounded-xl p-6 border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center mb-4">
<div class="p-2 bg-[var(--bg-accent)] rounded-lg mr-3">
<i class="fas fa-cogs text-[var(--text-accent)] text-lg"></i>
</div>
<h3 class="text-xl font-semibold text-[var(--text-primary)]"
data-i18n="aboutPage.systemConfiguration">System Configuration</h3>
</div>
<div class="space-y-4">
<div class="border-l-4 border-[var(--border-accent)] pl-4">
<h4 class="font-medium text-[var(--text-primary)] mb-2"
data-i18n="aboutPage.largeLanguageModel">Large Language Model</h4>
<div class="space-y-1 text-sm">
<p class="text-[var(--text-secondary)]">
<span class="font-medium" data-i18n="aboutPage.model">Model:</span>
<span id="llm-model"
class="font-mono text-[var(--text-primary)] bg-[var(--bg-tertiary)] px-2 py-1 rounded ml-1">Loading...</span>
</p>
<p class="text-[var(--text-secondary)]">
<span class="font-medium"
data-i18n="aboutPage.endpoint">Endpoint:</span>
<span id="llm-endpoint"
class="font-mono text-xs text-[var(--text-muted)] break-all ml-1">Loading...</span>
</p>
</div>
</div>
<div class="border-l-4 border-green-500 pl-4">
<h4 class="font-medium text-[var(--text-primary)] mb-2"
data-i18n="aboutPage.speechRecognition">Speech Recognition</h4>
<div class="space-y-1 text-sm">
<p class="text-[var(--text-secondary)]">
<span class="font-medium" data-i18n="aboutPage.whisperApi">Whisper
API:</span>
<span id="whisper-endpoint"
class="font-mono text-xs text-[var(--text-muted)] ml-1">Loading...</span>
</p>
<p class="text-[var(--text-secondary)]">
<span class="font-medium" data-i18n="aboutPage.asrEnabled">ASR
Enabled:</span>
<span id="asr-status" class="ml-1">Loading...</span>
</p>
<div id="asr-endpoint-row" style="display: none;">
<p class="text-[var(--text-secondary)]">
<span class="font-medium" data-i18n="aboutPage.asrEndpoint">ASR
Endpoint:</span>
<span id="asr-endpoint"
class="font-mono text-xs text-[var(--text-muted)] ml-1">-</span>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Project Information -->
<!-- <div class="bg-[var(--bg-secondary)] rounded-xl p-6 border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center mb-4">
<div class="p-2 bg-gray-800 rounded-lg mr-3">
<i class="fab fa-github text-white text-lg"></i>
</div>
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="aboutPage.projectLinks">Project Links</h3>
</div>
<div class="space-y-3">
<a href="https://github.com/murtaza-nasir/speakr" target="_blank" rel="noopener noreferrer"
class="flex items-center p-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-all duration-200 group transform hover:scale-[1.02]">
<i class="fab fa-github text-xl mr-3"></i>
<div class="flex-1">
<div class="font-medium" data-i18n="aboutPage.githubRepository">GitHub Repository</div>
<div class="text-sm text-gray-300" data-i18n="aboutPage.githubDescription">Source code, issues, and releases</div>
</div>
<i class="fas fa-external-link-alt text-sm opacity-70 group-hover:opacity-100"></i>
</a>
<a href="https://hub.docker.com/r/learnedmachine/speakr" target="_blank" rel="noopener noreferrer"
class="flex items-center p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all duration-200 group transform hover:scale-[1.02]">
<i class="fab fa-docker text-xl mr-3"></i>
<div class="flex-1">
<div class="font-medium" data-i18n="aboutPage.dockerHub">Docker Hub</div>
<div class="text-sm text-blue-100" data-i18n="aboutPage.dockerDescription">Official Docker images</div>
</div>
<i class="fas fa-external-link-alt text-sm opacity-70 group-hover:opacity-100"></i>
</a>
<a href="https://murtaza-nasir.github.io/speakr" target="_blank" rel="noopener noreferrer"
class="flex items-center p-3 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-all duration-200 group transform hover:scale-[1.02]">
<i class="fas fa-book text-xl mr-3"></i>
<div class="flex-1">
<div class="font-medium" data-i18n="aboutPage.documentation">Documentation</div>
<div class="text-sm text-green-100" data-i18n="aboutPage.documentationDescription">User guide and tutorials</div>
</div>
<i class="fas fa-external-link-alt text-sm opacity-70 group-hover:opacity-100"></i>
</a>
</div>
<div class="mt-6 p-3 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)]">
<p class="text-sm text-[var(--text-secondary)] leading-relaxed">
<i class="fas fa-info-circle text-[var(--text-accent)] mr-2"></i>
<span data-i18n="aboutPage.projectDescription">Transform your audio recordings into organized, searchable notes with AI-powered transcription, summarization, and interactive chat features.</span>
</p>
</div>
</div> -->
</div>
<!-- Key Features -->
<div
class="bg-[var(--bg-secondary)] rounded-xl p-6 border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center mb-6">
<div class="p-2 bg-purple-600 rounded-lg mr-3">
<i class="fas fa-star text-white text-lg"></i>
</div>
<h3 class="text-xl font-semibold text-[var(--text-primary)]"
data-i18n="aboutPage.keyFeatures">Key Features</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg mr-3 mt-1">
<i class="fas fa-microphone text-blue-600 dark:text-blue-400"></i>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-1"
data-i18n="aboutPage.audioTranscription">Audio Transcription</h4>
<p class="text-sm text-[var(--text-secondary)]"
data-i18n="aboutPage.audioTranscriptionDesc">Whisper API and custom ASR
support with high accuracy</p>
</div>
</div>
<div
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg mr-3 mt-1">
<i class="fas fa-robot text-purple-600 dark:text-purple-400"></i>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-1"
data-i18n="aboutPage.aiSummarization">AI Summarization</h4>
<p class="text-sm text-[var(--text-secondary)]"
data-i18n="aboutPage.aiSummarizationDesc">OpenRouter and Ollama
integration with custom prompts</p>
</div>
</div>
<div
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg mr-3 mt-1">
<i class="fas fa-users text-green-600 dark:text-green-400"></i>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-1"
data-i18n="aboutPage.speakerDiarization">Speaker Diarization</h4>
<p class="text-sm text-[var(--text-secondary)]"
data-i18n="aboutPage.speakerDiarizationDesc">Identify and label
different speakers automatically</p>
</div>
</div>
<div
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
<div class="p-2 bg-orange-100 dark:bg-orange-900 rounded-lg mr-3 mt-1">
<i class="fas fa-comments text-orange-600 dark:text-orange-400"></i>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-1"
data-i18n="aboutPage.interactiveChat">Interactive Chat</h4>
<p class="text-sm text-[var(--text-secondary)]"
data-i18n="aboutPage.interactiveChatDesc">Chat with your transcriptions
using AI</p>
</div>
</div>
<div
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
<div class="p-2 bg-indigo-100 dark:bg-indigo-900 rounded-lg mr-3 mt-1">
<i class="fas fa-search text-indigo-600 dark:text-indigo-400"></i>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-1"
data-i18n="aboutPage.inquireMode">Inquire Mode</h4>
<p class="text-sm text-[var(--text-secondary)]"
data-i18n="aboutPage.inquireModeDesc">Semantic search across all your
recordings</p>
</div>
</div>
<div
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
<div class="p-2 bg-teal-100 dark:bg-teal-900 rounded-lg mr-3 mt-1">
<i class="fas fa-share-alt text-teal-600 dark:text-teal-400"></i>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-1"
data-i18n="aboutPage.sharingExport">Sharing & Export</h4>
<p class="text-sm text-[var(--text-secondary)]"
data-i18n="aboutPage.sharingExportDesc">Share recordings and export to
various formats</p>
</div>
</div>
</div>
</div>
<!-- Technology Stack -->
<div
class="bg-[var(--bg-secondary)] rounded-xl p-6 border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center mb-4">
<div class="p-2 bg-red-500 rounded-lg mr-3">
<i class="fas fa-code text-white text-lg"></i>
</div>
<h3 class="text-xl font-semibold text-[var(--text-primary)]"
data-i18n="aboutPage.technologyStack">Technology Stack</h3>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="text-center p-3 bg-[var(--bg-tertiary)] rounded-lg">
<i class="fab fa-python text-3xl text-blue-500 mb-2"></i>
<p class="text-sm font-medium text-[var(--text-primary)]">Python Flask</p>
<p class="text-xs text-[var(--text-muted)]" data-i18n="aboutPage.backend">
Backend</p>
</div>
<div class="text-center p-3 bg-[var(--bg-tertiary)] rounded-lg">
<i class="fab fa-vuejs text-3xl text-green-500 mb-2"></i>
<p class="text-sm font-medium text-[var(--text-primary)]">Vue.js</p>
<p class="text-xs text-[var(--text-muted)]" data-i18n="aboutPage.frontend">
Frontend</p>
</div>
<div class="text-center p-3 bg-[var(--bg-tertiary)] rounded-lg">
<i class="fas fa-database text-3xl text-purple-500 mb-2"></i>
<p class="text-sm font-medium text-[var(--text-primary)]">SQLite</p>
<p class="text-xs text-[var(--text-muted)]" data-i18n="aboutPage.database">
Database</p>
</div>
<div class="text-center p-3 bg-[var(--bg-tertiary)] rounded-lg">
<i class="fab fa-docker text-3xl text-blue-600 mb-2"></i>
<p class="text-sm font-medium text-[var(--text-primary)]">Docker</p>
<p class="text-xs text-[var(--text-muted)]" data-i18n="aboutPage.deployment">
Deployment</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
<span id="footer-text">{{ html_title }} &copy; {{ now.year }}</span>
</footer>
</div>
<!-- Change Password Modal -->
<div id="changePasswordModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="account.changePassword">Change
Password</h3>
<button id="closeModalBtn"
class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">&times;</button>
</div>
<form id="changePasswordForm" method="POST" action="/tool/speakr/change_password">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="mb-4">
<label for="current_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="changePasswordModal.currentPassword">Current Password</label>
<input type="password" id="current_password" name="current_password" required
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="mb-4">
<label for="new_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="changePasswordModal.newPassword">New Password</label>
<input type="password" id="new_password" name="new_password" required minlength="8"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<p class="text-xs text-[var(--text-muted)] mt-1"
data-i18n="changePasswordModal.passwordRequirement">Password must be at least 8 characters long
</p>
</div>
<div class="mb-6">
<label for="confirm_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="changePasswordModal.confirmPassword">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" required
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="flex justify-end space-x-3">
<button type="button" id="cancelBtn"
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">取消</button>
<button type="submit"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]"
data-i18n="account.changePassword">Change Password</button>
</div>
</form>
</div>
</div>
<!-- Manage Speakers Modal -->
<div id="manageSpeakersModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="account.manageSpeakers">Manage
Speakers</h3>
<button id="closeSpeakersModalBtn"
class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">&times;</button>
</div>
<div class="mb-4">
<p class="text-sm text-[var(--text-muted)] mb-4">
<span data-i18n="manageSpeakersModal.description">Manage your saved speakers. These are
automatically saved when you use speaker names in your recordings.</span>
</p>
<!-- Loading state -->
<div id="speakersLoading" class="text-center py-8 hidden">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="text-[var(--text-muted)] mt-2" data-i18n="manageSpeakersModal.loadingSpeakers">Loading
speakers...</p>
</div>
<!-- Error state -->
<div id="speakersError"
class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span id="speakersErrorMessage" data-i18n="manageSpeakersModal.failedToLoad">Failed to load
speakers</span>
</div>
<!-- Empty state -->
<div id="speakersEmpty" class="text-center py-8 hidden">
<i class="fas fa-users text-4xl text-[var(--text-light)] mb-4"></i>
<p class="text-[var(--text-muted)]" data-i18n="manageSpeakersModal.noSpeakersYet">No speakers saved
yet</p>
<p class="text-sm text-[var(--text-light)]" data-i18n="manageSpeakersModal.speakersWillAppear">
Speakers will appear here when you use speaker names in your recordings</p>
</div>
<!-- Speakers list -->
<div id="speakersList" class="space-y-2 max-h-96 overflow-y-auto">
<!-- Speakers will be populated here -->
</div>
</div>
<div class="flex justify-between items-center pt-4 border-t border-[var(--border-primary)]">
<div class="text-sm text-[var(--text-muted)]">
<span><span id="speakersCount">0</span> <span data-i18n="manageSpeakersModal.speakersSaved">speakers
saved</span></span>
</div>
<div class="flex space-x-3">
<button id="deleteAllSpeakersBtn"
class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
<i class="fas fa-trash mr-2"></i> <span data-i18n="buttons.deleteAll">Delete All</span>
</button>
<button id="closeSpeakersBtn"
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">
<span data-i18n="buttons.close">Close</span>
</button>
</div>
</div>
</div>
</div>
<!-- Delete All Speakers Confirmation Modal -->
<div id="deleteAllSpeakersModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex items-center mb-4">
<i class="fas fa-exclamation-triangle text-[var(--text-danger)] text-2xl mr-3"></i>
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="buttons.deleteAll">Delete All
Speakers</h3>
</div>
<p class="text-[var(--text-secondary)] mb-6">
<span data-i18n="deleteAllSpeakersModal.confirmMessage">Are you sure you want to delete all saved
speakers? This action cannot be undone.</span>
</p>
<div class="flex justify-end space-x-3">
<button id="cancelDeleteAllBtn"
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">
<span data-i18n="buttons.cancel">取消</span>
</button>
<button id="confirmDeleteAllBtn"
class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)]">
<i class="fas fa-trash mr-2"></i> Delete All
</button>
</div>
</div>
</div>
<!-- Create/Edit Tag Modal -->
<div id="tagModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 id="tagModalTitle" class="text-xl font-semibold text-[var(--text-primary)]"
data-i18n="editTagModal.createTitle">Create Tag</h3>
<button id="closeTagModalBtn"
class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">&times;</button>
</div>
<form id="tagForm" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" id="tagId" name="tag_id">
<div>
<label for="tagName" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="editTagModal.tagName">Tag Name *</label>
<input type="text" id="tagName" name="name" required maxlength="50"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
placeholder="e.g., Meetings, Interviews">
</div>
<div>
<label for="tagColor" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="editTagModal.color">Color</label>
<div class="flex items-center space-x-3">
<input type="color" id="tagColor" name="color" value="#3B82F6"
class="w-12 h-10 rounded border border-[var(--border-secondary)]">
<span class="text-sm text-[var(--text-muted)]" data-i18n="editTagModal.colorDescription">Choose
a color for easy identification</span>
</div>
</div>
<div>
<label for="tagScope"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1">标签范围{{current_user.is_admin}}</label>
<select id="tagScope" name="visibility" required
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="global">全局</option>
<option value="private">个人</option>
</select>
</div>
<div>
<label for="tagCustomPrompt" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
data-i18n="editTagModal.customPrompt">Custom Summary Prompt</label>
<textarea id="tagCustomPrompt" name="custom_prompt" rows="4"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
placeholder="Optional: Custom prompt for generating summaries for recordings with this tag"
data-i18n-placeholder="editTagModal.customPromptPlaceholder"></textarea>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="editTagModal.leaveBlankPrompt">Leave
blank to use your default summary prompt</p>
</div>
{% if use_asr_endpoint %}
<div class="border-t border-[var(--border-primary)] pt-4">
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3"
data-i18n="editTagModal.asrDefaultSettings">ASR Default Settings</h4>
<p class="text-xs text-[var(--text-light)] mb-3" data-i18n="editTagModal.asrSettingsDescription">
These settings will be applied by default when using this tag with ASR transcription</p>
<div class="space-y-3">
<div>
<label for="tagLanguage" class="block text-sm font-medium text-[var(--text-muted)] mb-1"
data-i18n="editTagModal.defaultLanguage">Default Language</label>
<input type="text" id="tagLanguage" name="default_language" maxlength="10"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
placeholder="e.g., en, es, zh">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="tagMinSpeakers"
class="block text-sm font-medium text-[var(--text-muted)] mb-1"
data-i18n="editTagModal.minSpeakers">Min Speakers</label>
<input type="number" id="tagMinSpeakers" name="default_min_speakers" min="1"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
</div>
<div>
<label for="tagMaxSpeakers"
class="block text-sm font-medium text-[var(--text-muted)] mb-1"
data-i18n="editTagModal.maxSpeakers">Max Speakers</label>
<input type="number" id="tagMaxSpeakers" name="default_max_speakers" min="1"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
</div>
</div>
</div>
{% endif %}
<div class="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<button type="button" id="cancelTagBtn"
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]"
data-i18n="buttons.cancel">取消</button>
<button type="submit" id="saveTagBtn"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
<i class="fas fa-save mr-2"></i> <span data-i18n="buttons.createTag">Save Tag</span>
</button>
</div>
</form>
</div>
</div>
<script>
let voices = []; // 存储声纹数据
let editIndex = null;
let deleteIndex = null;
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
let isSavingVoice = false;
let modetypes = "add"
let modechooseIndex = 0
// 打开添加/编辑弹框
async function openModal(mode, index) {
document.getElementById('voiceModal').style.display = 'flex';
document.getElementById('audioPreview').style.display = 'none';
document.getElementById('voiceFile').value = '';
document.getElementById('voiceName').value = '';
// document.getElementById('voiceDesc').value = '';
modetypes = mode
modechooseIndex = index
if (mode === 'add') {
document.getElementById('modalTitle').innerText = '添加说话人';
editIndex = null;
} else if (mode === 'edit') {
document.getElementById('modalTitle').innerText = '编辑说话人';
editIndex = index;
const v = voices[index];
document.getElementById('voiceName').value = v.name;
if (v.id) {
try {
const response = await fetch(`/tool/speakr/api/voiceprints/${v.id}/audio`);
if (!response.ok) throw new Error(`获取音频失败:${response.status}`);
const blob = await response.blob();
const audioURL = URL.createObjectURL(blob);
const audio = document.getElementById('audioPreview');
audio.src = audioURL;
audio.style.display = 'block';
v.audio = blob;
audioChunks = [blob]; // <-- 新增
console.log("audioChunks 已从远程 Blob 恢复:", audioChunks);
console.log('声纹音频已加载并回显:', v.name);
} catch (err) {
console.error('获取声纹音频失败:', err);
alert('无法加载该说话人的音频文件。');
}
}
}
}
// 关闭弹框
function closeModal() {
document.getElementById('voiceModal').style.display = 'none';
}
async function synchronize() {
const response = await fetch(`/tool/speakr/api/voiceprints/sync-local-to-platform`, {
method: "POST",
});
const data = await response.json();
console.log(data,'data');
// showMessage(`✅ ${data.message}`, 'success');
}
// 保存声纹信息
async function saveVoice() {
if (isSavingVoice) {
return;
}
const name = document.getElementById('voiceName').value.trim();
// const desc
// = document.getElementById('voiceDesc').value.trim();
const fileInput = document.getElementById('voiceFile').files[0];
if (!name) { alert('名称为必填'); return; }
if (!fileInput && !audioChunks.length && editIndex === null) {
alert('请上传或录音声纹');
return;
}
let audioBlob = fileInput ? fileInput : new Blob(audioChunks, { type: 'audio/webm' });
// 测试音频文件
const reader = new FileReader();
reader.onload = function(e) {
console.log("audioBlob base64:", e.target.result);
};
reader.readAsDataURL(audioBlob);
console.log("audioBlob type:", audioBlob.type);
console.log("audioBlob size:", audioBlob.size);
console.log("audioBlob instanceof File:", audioBlob instanceof File);
console.log("audioBlob instanceof Blob:", audioBlob instanceof Blob);
console.log("audioBlob:", audioBlob);
// 使用 FormData 发送音频文件
const formData = new FormData();
formData.append("name", name);
const uploadFileName = fileInput && fileInput.name ? fileInput.name : `voiceprint_${Date.now()}.webm`;
formData.append("voice_data", audioBlob, uploadFileName);
isSavingVoice = true;
const saveBtn = document.getElementById('saveVoiceBtn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.dataset.originalText = saveBtn.textContent;
saveBtn.textContent = '保存中...';
}
if (modetypes == 'add') {
try {
const response = await fetch("/tool/speakr/api/voiceprints", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
showMessage(`${data.error}`, 'error');
return
}
closeModal();
audioChunks = [];
renderList()
showMessage(`${data.message}`, 'success');
} catch (err) {
// console.error("上传出错:", err);
// alert("上传失败:" + err.message);
showMessage(`${err.message}`, 'error');
} finally {
isSavingVoice = false;
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = saveBtn.dataset.originalText || '保存';
}
}
} else {
try {
let v = voices[modechooseIndex]
const response = await fetch(`/tool/speakr/api/voiceprints/${v.id}`, {
method: "PUT",
body: formData,
});
const data = await response.json();
if (!response.ok) {
showMessage(`${data.error}`, 'error');
return
}
closeModal();
audioChunks = [];
renderList()
showMessage(`${data.message}`, 'success');
} catch (err) {
// console.error("上传出错:", err);
// alert("上传失败:" + err.message);
showMessage(`${err.message}`, 'error');
} finally {
isSavingVoice = false;
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = saveBtn.dataset.originalText || '保存';
}
}
}
}
function showMessage(msg, type, duration = 3000) {
const messageDiv = document.getElementById('message');
if (type === 'error') {
messageDiv.style.background = '#f44336'; // 红色
} else if (type === 'warning') {
messageDiv.style.background = '#ff9800'; // 橙色(可选)
} else {
messageDiv.style.background = '#4caf50'; // 默认绿色(成功)
}
messageDiv.textContent = msg;
messageDiv.style.display = 'block';
setTimeout(() => {
messageDiv.style.display = 'none';
}, duration);
}
// 渲染列表
async function renderList() {
const response = await fetch(`/tool/speakr/api/voiceprints`, {
method: "GET",
});
const data = await response.json();
// console.log(data,'data');
voices = data.voiceprints
const tbody = document.querySelector('#voiceList tbody');
tbody.innerHTML = '';
voices.forEach((v, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${v.name}</td>
<td>${v.id}</td>
<td>
<button class="btn-icon" onclick="openModal('edit', ${i})">✏️</button>
<button class="btn-icon" onclick="openDeleteModal(${i})">🗑️</button>
</td>
`;
tbody.appendChild(tr);
});
}
// 删除逻辑
function openDeleteModal(index) {
deleteIndex = index;
document.getElementById('confirmDeleteModal').style.display = 'flex';
}
function closeDeleteModal() {
document.getElementById('confirmDeleteModal').style.display = 'none';
deleteIndex = null;
}
async function confirmDelete() {
// if (deleteIndex !== null) voices.splice(deleteIndex, 1);
console.log(voices[deleteIndex]);
let v = voices[deleteIndex]
const response = await fetch(`/tool/speakr/api/voiceprints/${v.id}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) {
showMessage(`${data.error}`, 'error');
return
}
console.log(data, 'data');
renderList();
closeDeleteModal();
showMessage(`${data.message}`, 'success');
}
// 清空上传的文件
function clearUploadFile() {
const fileInput = document.getElementById('voiceFile');
fileInput.value = ''; // 清空文件选择
console.log('✅ 已清空上传的文件');
// 如果当前显示的音频是上传的文件,也清除
const audio = document.getElementById('audioPreview');
if (audio.src && audio.src.startsWith('blob:')) {
audio.src = '';
audio.style.display = 'none';
}
}
// 清空录音内容
function clearRecording() {
audioChunks = [];
console.log('✅ 已清空录音内容');
const audio = document.getElementById('audioPreview');
audio.src = '';
audio.style.display = 'none';
}
function startRecording(btn) {
if (isRecording) {
stopRecording(btn);
return;
}
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.start();
isRecording = true;
// 按钮切换为录音中样式
btn.textContent = '停止';
btn.classList.add('btn-recording');
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
mediaRecorder.onstop = () => {
isRecording = false;
btn.textContent = '🎤 录音';
btn.classList.remove('btn-recording');
// 生成音频预览
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
const audioURL = URL.createObjectURL(audioBlob);
const audio = document.getElementById('audioPreview');
audio.src = audioURL;
audio.style.display = 'block';
// 释放麦克风资源
stream.getTracks().forEach(track => track.stop());
};
})
.catch(err => {
alert('无法访问麦克风:' + err.message);
});
}
function stopRecording(btn) {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
}
}
document.addEventListener('DOMContentLoaded', async function () {
// Initialize i18n
await initializeI18n();
// Hide loading overlay after everything is ready
if (window.AppLoader) {
AppLoader.waitForReady();
}
// Setup scroll indicators for tabs
const tabsContainer = document.getElementById('tabs-container');
const leftIndicator = document.getElementById('left-scroll-indicator');
const rightIndicator = document.getElementById('right-scroll-indicator');
function updateScrollIndicators() {
if (tabsContainer && leftIndicator && rightIndicator) {
const scrollLeft = tabsContainer.scrollLeft;
const scrollWidth = tabsContainer.scrollWidth;
const clientWidth = tabsContainer.clientWidth;
// Show/hide left indicator
if (scrollLeft > 10) {
leftIndicator.classList.remove('opacity-0');
leftIndicator.classList.add('opacity-100');
} else {
leftIndicator.classList.add('opacity-0');
leftIndicator.classList.remove('opacity-100');
}
// Show/hide right indicator
if (scrollLeft < scrollWidth - clientWidth - 10) {
rightIndicator.classList.remove('opacity-0');
rightIndicator.classList.add('opacity-100');
} else {
rightIndicator.classList.add('opacity-0');
rightIndicator.classList.remove('opacity-100');
}
}
}
if (tabsContainer) {
tabsContainer.addEventListener('scroll', updateScrollIndicators);
window.addEventListener('resize', updateScrollIndicators);
// Initial check - run immediately and after a delay
updateScrollIndicators();
setTimeout(updateScrollIndicators, 100);
setTimeout(updateScrollIndicators, 500);
}
const tabAccount = document.getElementById('tab-account');
const tabPrompts = document.getElementById('tab-prompts');
const tabShares = document.getElementById('tab-shares');
const tabTags = document.getElementById('tab-tags');
const voiceprint = document.getElementById('tab-voiceprint');
const tabTemplates = document.getElementById('tab-templates');
const tabAbout = document.getElementById('tab-about');
const contentAccount = document.getElementById('content-account');
const contentPrompts = document.getElementById('content-prompts');
const contentShares = document.getElementById('content-shares');
const contentSpeakers = document.getElementById('content-speakers');
const contentTags = document.getElementById('content-tags');
const contentTemplates = document.getElementById('content-templates');
const contentVoiceprint = document.getElementById('content-voiceprint');
const contentAbout = document.getElementById('content-about');
const sharesContainer = document.getElementById('shares-container');
// Simplified tab switching
const tabSpeakers = document.getElementById('tab-speakers');
const tabs = [
{ tab: tabAccount, content: contentAccount, name: 'account' },
{ tab: tabPrompts, content: contentPrompts, name: 'prompts' },
{ tab: tabShares, content: contentShares, name: 'shares' },
{ tab: tabSpeakers, content: contentSpeakers, name: 'speakers' },
{ tab: tabTags, content: contentTags, name: 'tags' },
{ tab: voiceprint, content: contentVoiceprint, name: 'voiceprint' },
{ tab: tabTemplates, content: contentTemplates, name: 'templates' },
{ tab: tabAbout, content: contentAbout, name: 'about' }
];
function switchToTab(targetName) {
tabs.forEach(({ tab, content, name }) => {
if (name === targetName) {
// Active tab
tab.classList.add('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
tab.classList.remove('border-transparent', 'text-[var(--text-muted)]', 'hover:text-[var(--text-secondary)]', 'hover:border-[var(--border-secondary)]');
content.classList.remove('hidden');
} else {
// Inactive tab
tab.classList.add('border-transparent', 'text-[var(--text-muted)]', 'hover:text-[var(--text-secondary)]', 'hover:border-[var(--border-secondary)]');
tab.classList.remove('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
content.classList.add('hidden');
}
});
// Load content when switching tabs
if (targetName === 'speakers') {
loadSpeakersTab();
} else if (targetName === 'shares') {
loadShares();
} else if (targetName === 'tags') {
loadTags();
} else if (targetName === 'templates') {
loadTemplates();
} else if (targetName === 'about') {
loadSystemInfo();
} else if (targetName === 'voiceprint') {
loadVoiceprint();
}
}
function showAccountTab() {
switchToTab('account');
}
function showPromptsTab() {
switchToTab('prompts');
}
function showSharesTab() {
switchToTab('shares');
}
function showSpeakersTab() {
switchToTab('speakers');
}
function showTagsTab() {
switchToTab('tags');
}
function showTemplatesTab() {
switchToTab('templates');
}
function showVoiceprint() {
switchToTab('voiceprint');
}
function showAboutTab() {
switchToTab('about');
}
tabAccount.addEventListener('click', (e) => {
e.preventDefault();
showAccountTab();
});
tabPrompts.addEventListener('click', (e) => {
e.preventDefault();
showPromptsTab();
});
tabShares.addEventListener('click', (e) => {
e.preventDefault();
showSharesTab();
});
tabSpeakers.addEventListener('click', (e) => {
e.preventDefault();
showSpeakersTab();
});
tabTags.addEventListener('click', (e) => {
e.preventDefault();
showTagsTab();
});
tabTemplates.addEventListener('click', (e) => {
e.preventDefault();
showTemplatesTab();
});
tabAbout.addEventListener('click', (e) => {
e.preventDefault();
showAboutTab();
});
voiceprint.addEventListener('click', (e) => {
e.preventDefault();
showVoiceprint();
});
// Load speakers for the Speakers Management tab
let speakersTabData = [];
let speakersTabLoaded = false;
async function loadSpeakersTab() {
// Don't reload if already loaded
if (speakersTabLoaded) return;
const speakersTabLoading = document.getElementById('speakersTabLoading');
const speakersTabError = document.getElementById('speakersTabError');
const speakersTabErrorMessage = document.getElementById('speakersTabErrorMessage');
const speakersTabEmpty = document.getElementById('speakersTabEmpty');
const speakersTabGrid = document.getElementById('speakersTabGrid');
const speakersTabCount = document.getElementById('speakersTabCount');
const deleteAllSpeakersTabBtn = document.getElementById('deleteAllSpeakersTabBtn');
// Show loading state
speakersTabLoading.classList.remove('hidden');
speakersTabError.classList.add('hidden');
speakersTabEmpty.classList.add('hidden');
speakersTabGrid.classList.add('hidden');
try {
const response = await fetch('/tool/speakr/speakers');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
speakersTabData = await response.json();
speakersTabLoaded = true;
// Hide loading state
speakersTabLoading.classList.add('hidden');
if (speakersTabData.length === 0) {
speakersTabEmpty.classList.remove('hidden');
speakersTabGrid.classList.add('hidden');
deleteAllSpeakersTabBtn.disabled = true;
} else {
speakersTabEmpty.classList.add('hidden');
speakersTabGrid.classList.remove('hidden');
deleteAllSpeakersTabBtn.disabled = false;
renderSpeakersTabGrid();
}
// Update count
speakersTabCount.textContent = speakersTabData.length;
} catch (error) {
console.error('Error loading speakers:', error);
speakersTabLoading.classList.add('hidden');
speakersTabError.classList.remove('hidden');
speakersTabEmpty.classList.add('hidden');
speakersTabGrid.classList.add('hidden');
speakersTabErrorMessage.textContent = error.message;
speakersTabCount.textContent = '0';
}
}
function renderSpeakersTabGrid() {
const speakersTabGrid = document.getElementById('speakersTabGrid');
speakersTabGrid.innerHTML = '';
speakersTabData.forEach(speaker => {
const createdDate = new Date(speaker.created_at).toLocaleDateString();
const lastUsedDate = new Date(speaker.last_used).toLocaleDateString();
const speakerCard = document.createElement('div');
speakerCard.className = 'bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] p-4 hover:border-[var(--border-accent)] transition-colors duration-200';
speakerCard.innerHTML = `
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center flex-1 min-w-0">
<i class="fas fa-user text-[var(--text-accent)] mr-2 flex-shrink-0"></i>
<h3 class="font-medium text-base text-[var(--text-primary)] truncate">${escapeHtml(speaker.name)}</h3>
</div>
<button class="delete-speaker-tab-btn ml-2 p-1.5 text-[var(--text-danger)] hover:bg-[var(--bg-danger-light)] rounded-md transition-colors duration-200 flex-shrink-0"
data-speaker-id="${speaker.id}"
data-speaker-name="${escapeHtml(speaker.name)}"
data-i18n-title="buttons.deleteSpeaker">
<i class="fas fa-trash text-sm"></i>
</button>
</div>
<div class="space-y-1 text-xs text-[var(--text-muted)]">
<p class="flex items-center">
<i class="fas fa-chart-line mr-1.5 text-xs opacity-60"></i>
<span>Used ${speaker.use_count} time${speaker.use_count !== 1 ? 's' : ''}</span>
</p>
<p class="flex items-center">
<i class="fas fa-clock mr-1.5 text-xs opacity-60"></i>
<span>Last: ${lastUsedDate}</span>
</p>
<p class="flex items-center">
<i class="fas fa-calendar-plus mr-1.5 text-xs opacity-60"></i>
<span>Added: ${createdDate}</span>
</p>
</div>
</div>
`;
// Add delete functionality
const deleteBtn = speakerCard.querySelector('.delete-speaker-tab-btn');
deleteBtn.addEventListener('click', () => deleteSpeakerFromTab(speaker.id, speaker.name));
speakersTabGrid.appendChild(speakerCard);
});
}
async function deleteSpeakerFromTab(speakerId, speakerName) {
if (!confirm(`Are you sure you want to delete "${speakerName}"?`)) {
return;
}
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/tool/speakr/speakers/${speakerId}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
// Remove from local array and re-render
speakersTabData = speakersTabData.filter(s => s.id !== speakerId);
if (speakersTabData.length === 0) {
document.getElementById('speakersTabEmpty').classList.remove('hidden');
document.getElementById('speakersTabGrid').classList.add('hidden');
document.getElementById('deleteAllSpeakersTabBtn').disabled = true;
} else {
renderSpeakersTabGrid();
}
document.getElementById('speakersTabCount').textContent = speakersTabData.length;
showToast(`Speaker "${speakerName}" deleted successfully`);
} catch (error) {
console.error('Error deleting speaker:', error);
showToast(`Failed to delete speaker: ${error.message}`, 'error');
}
}
// Delete all speakers from tab
document.getElementById('deleteAllSpeakersTabBtn')?.addEventListener('click', async () => {
if (!confirm('Are you sure you want to delete all speakers? This action cannot be undone.')) {
return;
}
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
// Delete all speakers one by one
const deletePromises = speakersTabData.map(speaker =>
fetch(`/speakers/${speaker.id}`, {
method: 'DELETE',
headers: headers
})
);
const results = await Promise.allSettled(deletePromises);
// Check for any failures
const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) {
console.error('Some speakers failed to delete:', failures);
showToast(`${failures.length} speaker(s) failed to delete`, 'error');
}
// Reset the tab state
speakersTabLoaded = false;
await loadSpeakersTab();
const successCount = results.length - failures.length;
if (successCount > 0) {
showToast(`${successCount} speaker(s) deleted successfully`);
}
} catch (error) {
console.error('Error deleting all speakers:', error);
showToast(`Failed to delete speakers: ${error.message}`, 'error');
}
});
async function loadSystemInfo() {
try {
const response = await fetch('/tool/speakr/api/system/info');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Update version in multiple places
document.getElementById('system-version').textContent = data.version;
// Update LLM info
document.getElementById('llm-endpoint').textContent = data.llm_endpoint;
document.getElementById('llm-model').textContent = data.llm_model;
// Update Whisper/ASR info
document.getElementById('whisper-endpoint').textContent = data.whisper_endpoint;
document.getElementById('asr-status').textContent = data.asr_enabled ? 'Yes' : 'No';
if (data.asr_enabled && data.asr_endpoint) {
document.getElementById('asr-endpoint').textContent = data.asr_endpoint;
document.getElementById('asr-endpoint-row').style.display = 'block';
}
} catch (error) {
document.getElementById('system-version').textContent = 'Failed to load';
document.getElementById('llm-endpoint').textContent = 'Failed to load';
document.getElementById('llm-model').textContent = 'Failed to load';
document.getElementById('whisper-endpoint').textContent = 'Failed to load';
document.getElementById('asr-status').textContent = 'Failed to load';
}
}
async function loadShares() {
sharesContainer.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin text-2xl"></i></div>';
try {
const response = await fetch('/tool/speakr/api/shares');
const shares = await response.json();
if (!response.ok) throw new Error(shares.error || 'Failed to load');
if (shares.length === 0) {
sharesContainer.innerHTML = `<p class="text-center text-[var(--text-muted)]">${t('sharedTranscriptsPage.noSharedTranscripts')}</p>`;
return;
}
sharesContainer.innerHTML = '';
shares.forEach(share => {
const shareEl = document.createElement('div');
shareEl.className = 'bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]';
shareEl.innerHTML = `
<div class="flex justify-between items-start">
<div>
<p class="font-semibold">${escapeHtml(share.recording_title)}</p>
<p class="text-sm text-[var(--text-muted)]">Shared on: ${share.created_at}</p>
</div>
<button data-share-id="${share.id}" class="delete-share-btn text-red-500 hover:text-red-700 p-1"><i class="fas fa-trash"></i></button>
</div>
<div class="mt-4 flex items-center gap-4">
<label class="flex items-center text-sm">
<input type="checkbox" data-share-id="${share.id}" data-share-prop="share_summary" class="share-checkbox h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" ${share.share_summary ? 'checked' : ''}>
<span class="ml-2">Share Summary</span>
</label>
<label class="flex items-center text-sm">
<input type="checkbox" data-share-id="${share.id}" data-share-prop="share_notes" class="share-checkbox h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" ${share.share_notes ? 'checked' : ''}>
<span class="ml-2">Share Notes</span>
</label>
</div>
<div class="mt-4">
<input value="{{ request.url_root }}share/${share.public_id}" readonly class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg text-sm">
</div>
`;
sharesContainer.appendChild(shareEl);
});
document.querySelectorAll('.delete-share-btn').forEach(btn => {
btn.addEventListener('click', handleDeleteShare);
});
document.querySelectorAll('.share-checkbox').forEach(box => {
box.addEventListener('change', handleUpdateShare);
});
} catch (error) {
sharesContainer.innerHTML = `<p class="text-center text-red-500">${t('errors.loadingShares')}: ${error.message}</p>`;
}
}
async function handleDeleteShare(event) {
const shareId = event.currentTarget.dataset.shareId;
if (!confirm('Are you sure you want to delete this share? This will revoke access to the public link.')) return;
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) throw new Error('Failed to delete');
loadShares();
} catch (error) {
alert('Error deleting share.');
}
}
async function handleUpdateShare(event) {
const shareId = event.currentTarget.dataset.shareId;
const prop = event.currentTarget.dataset.shareProp;
const value = event.currentTarget.checked;
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
method: 'PUT',
headers: headers,
body: JSON.stringify({ [prop]: value })
});
if (!response.ok) throw new Error('Failed to update');
} catch (error) {
alert('Error updating share.');
event.currentTarget.checked = !value; // Revert on failure
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Update footer with version
async function updateFooter() {
try {
const response = await fetch('/tool/speakr/api/system/info');
if (response.ok) {
const data = await response.json();
const footerText = document.getElementById('footer-text');
if (footerText && data.version) {
footerText.textContent = `{{ html_title }} ${data.version} © {{ now.year }}`;
}
}
} catch (error) {
console.log('Could not fetch version for footer');
}
}
// Update footer on page load
updateFooter();
// Dark mode toggle functionality
const darkModeToggle = document.getElementById('darkModeToggle');
const darkModeIcon = document.getElementById('darkModeIcon');
// Initialize dark mode based on localStorage or system preference
function initializeDarkMode() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedMode = localStorage.getItem('darkMode');
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
darkModeIcon.classList.remove('fa-moon');
darkModeIcon.classList.add('fa-sun');
} else {
document.documentElement.classList.remove('dark');
darkModeIcon.classList.remove('fa-sun');
darkModeIcon.classList.add('fa-moon');
}
}
// Toggle dark mode
darkModeToggle.addEventListener('click', () => {
const isDarkMode = document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', isDarkMode);
if (isDarkMode) {
darkModeIcon.classList.remove('fa-moon');
darkModeIcon.classList.add('fa-sun');
} else {
darkModeIcon.classList.remove('fa-sun');
darkModeIcon.classList.add('fa-moon');
}
});
// User dropdown functionality
const userDropdownButton = document.getElementById('userDropdownButton');
const userDropdownMenu = document.getElementById('userDropdownMenu');
userDropdownButton.addEventListener('click', () => {
userDropdownMenu.classList.toggle('hidden');
});
// Close dropdown when clicking outside
document.addEventListener('click', (event) => {
if (!userDropdownButton.contains(event.target) && !userDropdownMenu.contains(event.target)) {
userDropdownMenu.classList.add('hidden');
}
});
// Initialize dark mode on page load
initializeDarkMode();
// Change Password Modal functionality
const changePasswordBtn = document.getElementById('changePasswordBtn');
const changePasswordModal = document.getElementById('changePasswordModal');
const closeModalBtn = document.getElementById('closeModalBtn');
const cancelBtn = document.getElementById('cancelBtn');
const changePasswordForm = document.getElementById('changePasswordForm');
const newPasswordInput = document.getElementById('new_password');
const confirmPasswordInput = document.getElementById('confirm_password');
// Open modal
changePasswordBtn.addEventListener('click', () => {
changePasswordModal.classList.remove('hidden');
// Reset form
changePasswordForm.reset();
});
// Close modal functions
function closeModal() {
changePasswordModal.classList.add('hidden');
}
closeModalBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
// Close modal when clicking outside
changePasswordModal.addEventListener('click', (event) => {
if (event.target === changePasswordModal) {
closeModal();
}
});
// Form validation
changePasswordForm.addEventListener('submit', (event) => {
if (newPasswordInput.value !== confirmPasswordInput.value) {
event.preventDefault();
alert('New password and confirmation do not match.');
}
});
// Keep the modal functionality for backward compatibility if button still exists
const manageSpeakersBtn = document.getElementById('manageSpeakersBtn');
const manageSpeakersModal = document.getElementById('manageSpeakersModal');
const closeSpeakersModalBtn = document.getElementById('closeSpeakersModalBtn');
const closeSpeakersBtn = document.getElementById('closeSpeakersBtn');
const deleteAllSpeakersBtn = document.getElementById('deleteAllSpeakersBtn');
const deleteAllSpeakersModal = document.getElementById('deleteAllSpeakersModal');
const cancelDeleteAllBtn = document.getElementById('cancelDeleteAllBtn');
const confirmDeleteAllBtn = document.getElementById('confirmDeleteAllBtn');
const speakersLoading = document.getElementById('speakersLoading');
const speakersError = document.getElementById('speakersError');
const speakersErrorMessage = document.getElementById('speakersErrorMessage');
const speakersEmpty = document.getElementById('speakersEmpty');
const speakersList = document.getElementById('speakersList');
const speakersCount = document.getElementById('speakersCount');
let speakers = [];
// Open speakers modal only if button exists
if (manageSpeakersBtn) {
manageSpeakersBtn.addEventListener('click', () => {
manageSpeakersModal.classList.remove('hidden');
loadSpeakers();
});
}
// Close speakers modal functions
function closeSpeakersModal() {
manageSpeakersModal.classList.add('hidden');
}
closeSpeakersModalBtn.addEventListener('click', closeSpeakersModal);
closeSpeakersBtn.addEventListener('click', closeSpeakersModal);
// Close modal when clicking outside
manageSpeakersModal.addEventListener('click', (event) => {
if (event.target === manageSpeakersModal) {
closeSpeakersModal();
}
});
// Load speakers from API
async function loadSpeakers() {
showLoadingState();
try {
const response = await fetch('/tool/speakr/speakers');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
speakers = await response.json();
displaySpeakers();
} catch (error) {
console.error('Error loading speakers:', error);
showErrorState(error.message);
}
}
// Show loading state
function showLoadingState() {
speakersLoading.classList.remove('hidden');
speakersError.classList.add('hidden');
speakersEmpty.classList.add('hidden');
speakersList.classList.add('hidden');
}
// Show error state
function showErrorState(message) {
speakersLoading.classList.add('hidden');
speakersError.classList.remove('hidden');
speakersEmpty.classList.add('hidden');
speakersList.classList.add('hidden');
speakersErrorMessage.textContent = message;
updateSpeakersCount(0);
}
// Display speakers
function displaySpeakers() {
speakersLoading.classList.add('hidden');
speakersError.classList.add('hidden');
if (speakers.length === 0) {
speakersEmpty.classList.remove('hidden');
speakersList.classList.add('hidden');
deleteAllSpeakersBtn.disabled = true;
} else {
speakersEmpty.classList.add('hidden');
speakersList.classList.remove('hidden');
deleteAllSpeakersBtn.disabled = false;
renderSpeakersList();
}
updateSpeakersCount(speakers.length);
}
// Render speakers list
function renderSpeakersList() {
speakersList.innerHTML = '';
speakers.forEach(speaker => {
const speakerItem = createSpeakerItem(speaker);
speakersList.appendChild(speakerItem);
});
}
// Create speaker item element
function createSpeakerItem(speaker) {
const div = document.createElement('div');
div.className = 'flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:bg-[var(--bg-accent)] transition-colors duration-200';
const createdDate = new Date(speaker.created_at).toLocaleDateString();
const lastUsedDate = new Date(speaker.last_used).toLocaleDateString();
div.innerHTML = `
<div class="flex-1">
<div class="flex items-center">
<i class="fas fa-user text-[var(--text-muted)] mr-2"></i>
<span class="font-medium text-[var(--text-primary)]">${escapeHtml(speaker.name)}</span>
</div>
<div class="text-xs text-[var(--text-muted)] mt-1">
Used ${speaker.use_count} time${speaker.use_count !== 1 ? 's' : ''}
Last used: ${lastUsedDate}
Created: ${createdDate}
</div>
</div>
<button class="delete-speaker-btn ml-3 p-2 text-[var(--text-danger)] hover:bg-[var(--bg-danger-light)] rounded-md transition-colors duration-200"
data-speaker-id="${speaker.id}"
data-i18n-title="buttons.deleteSpeaker">
<i class="fas fa-trash text-sm"></i>
</button>
`;
// Add delete functionality
const deleteBtn = div.querySelector('.delete-speaker-btn');
deleteBtn.addEventListener('click', () => deleteSpeaker(speaker.id, speaker.name));
return div;
}
// Delete individual speaker
async function deleteSpeaker(speakerId, speakerName) {
if (!confirm(`Are you sure you want to delete "${speakerName}"?`)) {
return;
}
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/tool/speakr/speakers/${speakerId}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
// Remove from local array
speakers = speakers.filter(s => s.id !== speakerId);
displaySpeakers();
showToast(`Speaker "${speakerName}" deleted successfully`);
} catch (error) {
console.error('Error deleting speaker:', error);
showToast(`Failed to delete speaker: ${error.message}`, 'error');
}
}
// Delete all speakers
deleteAllSpeakersBtn.addEventListener('click', () => {
deleteAllSpeakersModal.classList.remove('hidden');
});
// Cancel delete all
cancelDeleteAllBtn.addEventListener('click', () => {
deleteAllSpeakersModal.classList.add('hidden');
});
// Close delete all modal when clicking outside
deleteAllSpeakersModal.addEventListener('click', (event) => {
if (event.target === deleteAllSpeakersModal) {
deleteAllSpeakersModal.classList.add('hidden');
}
});
// Confirm delete all speakers
confirmDeleteAllBtn.addEventListener('click', async () => {
deleteAllSpeakersModal.classList.add('hidden');
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
// Delete all speakers one by one
const deletePromises = speakers.map(speaker =>
fetch(`/speakers/${speaker.id}`, {
method: 'DELETE',
headers: headers
})
);
const results = await Promise.allSettled(deletePromises);
// Check for any failures
const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) {
console.error('Some speakers failed to delete:', failures);
showToast(`${failures.length} speaker(s) failed to delete`, 'error');
}
// Reload speakers list
await loadSpeakers();
showToast(`${speakers.length === 0 ? 'All speakers' : (results.length - failures.length) + ' speakers'} deleted successfully`);
} catch (error) {
console.error('Error deleting all speakers:', error);
showToast(`Failed to delete speakers: ${error.message}`, 'error');
}
});
// Update speakers count
function updateSpeakersCount(count) {
speakersCount.textContent = count;
}
// Utility function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Toast notification function
function showToast(message, type = 'success') {
// Create toast element
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg transition-all duration-300 transform translate-x-full ${type === 'error'
? 'bg-[var(--bg-danger)] text-[var(--text-button)]'
: 'bg-[var(--bg-success-light)] text-[var(--text-success-strong)]'
}`;
toast.innerHTML = `
<div class="flex items-center">
<i class="fas ${type === 'error' ? 'fa-exclamation-circle' : 'fa-check-circle'} mr-2"></i>
<span>${escapeHtml(message)}</span>
</div>
`;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.classList.remove('translate-x-full');
}, 10);
// Animate out and remove
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
}
// ===== TAG MANAGEMENT FUNCTIONALITY =====
// Tag management elements
const createTagBtn = document.getElementById('createTagBtn');
const tagModal = document.getElementById('tagModal');
const closeTagModalBtn = document.getElementById('closeTagModalBtn');
const cancelTagBtn = document.getElementById('cancelTagBtn');
const tagForm = document.getElementById('tagForm');
const tagModalTitle = document.getElementById('tagModalTitle');
const saveTagBtn = document.getElementById('saveTagBtn');
const tagsLoading = document.getElementById('tagsLoading');
const tagsError = document.getElementById('tagsError');
const tagsErrorMessage = document.getElementById('tagsErrorMessage');
const tagsEmpty = document.getElementById('tagsEmpty');
const tagsGrid = document.getElementById('tagsGrid');
let tags = [];
let isEditingTag = false;
// Load tags from API
async function loadTags() {
showTagsLoadingState();
try {
const response = await fetch('/tool/speakr/api/tags');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
tags = await response.json();
displayTags();
} catch (error) {
console.error('Error loading tags:', error);
showTagsErrorState(error.message);
}
}
async function loadVoiceprint() {
showTagsLoadingState();
renderList()
// try {
// const response = await fetch('/tool/speakr/api/tags');
// if (!response.ok) {
// throw new Error(`HTTP ${response.status}: ${response.statusText}`);
// }
// tags = await response.json();
// displayTags();
// } catch (error) {
// console.error('Error loading tags:', error);
// showTagsErrorState(error.message);
// }
}
// Show loading state
function showTagsLoadingState() {
tagsLoading.classList.remove('hidden');
tagsError.classList.add('hidden');
tagsEmpty.classList.add('hidden');
tagsGrid.classList.add('hidden');
}
// Show error state
function showTagsErrorState(message) {
tagsLoading.classList.add('hidden');
tagsError.classList.remove('hidden');
tagsEmpty.classList.add('hidden');
tagsGrid.classList.add('hidden');
tagsErrorMessage.textContent = message;
}
// Display tags
function displayTags() {
tagsLoading.classList.add('hidden');
tagsError.classList.add('hidden');
if (tags.length === 0) {
tagsEmpty.classList.remove('hidden');
tagsGrid.classList.add('hidden');
} else {
tagsEmpty.classList.add('hidden');
tagsGrid.classList.remove('hidden');
renderTagsGrid();
}
}
// Render tags grid
function renderTagsGrid() {
tagsGrid.innerHTML = '';
tags.forEach(tag => {
const tagCard = createTagCard(tag);
tagsGrid.appendChild(tagCard);
});
}
let frontendCurrentUser = {
isAdmin: {{ current_user.is_admin| tojson
}}
}
// Create tag card element
function createTagCard(tag) {
const div = document.createElement('div');
div.className = 'bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] hover:shadow-md transition-shadow duration-200';
console.log(frontendCurrentUser, 'current_user', tag.visibility);
div.innerHTML = `
<div class="flex items-start justify-between mb-3">
<div class="flex items-center">
<div class="w-4 h-4 rounded-full mr-3" style="background-color: ${escapeHtml(tag.color)}"></div>
<h3 class="font-semibold text-[var(--text-primary)]">${escapeHtml(tag.name)}${tag.visibility == "global" ? "(全局)" : "(个人)"}</h3>
</div>
${(frontendCurrentUser.isAdmin || tag.visibility != "global") ? `
<div class="flex space-x-1">
<button class="edit-tag-btn p-1 text-[var(--text-muted)] hover:text-[var(--text-accent)] rounded"
data-tag-id="${tag.id}" data-i18n-title="buttons.editTag">
<i class="fas fa-edit text-sm"></i>
</button>
<button class="delete-tag-btn p-1 text-[var(--text-muted)] hover:text-[var(--text-danger)] rounded"
data-tag-id="${tag.id}" data-i18n-title="buttons.deleteTag">
<i class="fas fa-trash text-sm"></i>
</button>
</div>
` : ''}
</div>
<div class="space-y-2 text-sm text-[var(--text-muted)]">
${tag.custom_prompt ? `
<div>
<span class="font-medium" data-i18n="tagManagement.customPrompt">Custom Prompt:</span>
<span class="text-xs">${escapeHtml(tag.custom_prompt.substring(0, 60))}${tag.custom_prompt.length > 60 ? '...' : ''}</span>
</div>
` : ''}
${tag.default_language || tag.default_min_speakers || tag.default_max_speakers ? `
<div class="pt-2 border-t border-[var(--border-secondary)]">
<span class="font-medium text-xs" data-i18n="tagManagement.asrDefaults">ASR Defaults:</span>
<div class="flex flex-wrap gap-2 mt-1">
${tag.default_language ? `<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-[var(--bg-accent)] text-[var(--text-accent)]">Lang: ${escapeHtml(tag.default_language)}</span>` : ''}
${tag.default_min_speakers ? `<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-[var(--bg-accent)] text-[var(--text-accent)]">Min: ${tag.default_min_speakers}</span>` : ''}
${tag.default_max_speakers ? `<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-[var(--bg-accent)] text-[var(--text-accent)]">Max: ${tag.default_max_speakers}</span>` : ''}
</div>
</div>
` : ''}
</div>
`;
// Add event listeners
const editBtn = div.querySelector('.edit-tag-btn');
const deleteBtn = div.querySelector('.delete-tag-btn');
if (frontendCurrentUser.isAdmin || tag.visibility != "global") {
editBtn.addEventListener('click', () => editTag(tag));
deleteBtn.addEventListener('click', () => deleteTag(tag.id, tag.name));
}
return div;
}
// Create tag modal functionality
createTagBtn.addEventListener('click', () => {
openTagModal();
});
// Close tag modal functions
function closeTagModal() {
tagModal.classList.add('hidden');
tagForm.reset();
isEditingTag = false;
document.getElementById('tagId').value = '';
}
closeTagModalBtn.addEventListener('click', closeTagModal);
cancelTagBtn.addEventListener('click', closeTagModal);
// Close modal when clicking outside
tagModal.addEventListener('click', (event) => {
if (event.target === tagModal) {
closeTagModal();
}
});
// Open tag modal for create
function openTagModal(tag = null) {
if (tag) {
// Edit mode
isEditingTag = true;
tagModalTitle.textContent = 'Edit Tag';
saveTagBtn.innerHTML = '<i class="fas fa-save mr-2"></i> Update Tag';
// Populate form
document.getElementById('tagId').value = tag.id;
document.getElementById('tagName').value = tag.name;
document.getElementById('tagColor').value = tag.color;
document.getElementById('tagCustomPrompt').value = tag.custom_prompt || '';
document.getElementById('tagScope').value = tag.visibility || '';
if (document.getElementById('tagLanguage')) {
document.getElementById('tagLanguage').value = tag.default_language || '';
}
if (document.getElementById('tagMinSpeakers')) {
document.getElementById('tagMinSpeakers').value = tag.default_min_speakers || '';
}
if (document.getElementById('tagMaxSpeakers')) {
document.getElementById('tagMaxSpeakers').value = tag.default_max_speakers || '';
}
} else {
// Create mode
isEditingTag = false;
tagModalTitle.textContent = 'Create Tag';
saveTagBtn.innerHTML = '<i class="fas fa-save mr-2"></i> Save Tag';
tagForm.reset();
document.getElementById('tagColor').value = '#3B82F6';
}
tagModal.classList.remove('hidden');
}
// Edit tag
function editTag(tag) {
openTagModal(tag);
}
// Delete tag
async function deleteTag(tagId, tagName) {
if (!confirm(`Are you sure you want to delete "${tagName}"?`)) {
return;
}
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/tool/speakr/api/tags/${tagId}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
// Remove from local array
tags = tags.filter(t => t.id !== tagId);
displayTags();
showToast(`Tag "${tagName}" deleted successfully`);
} catch (error) {
console.error('Error deleting tag:', error);
showToast(`Failed to delete tag: ${error.message}`, 'error');
}
}
// Save tag form submission
tagForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(tagForm);
const tagData = {
name: formData.get('name'),
color: formData.get('color'),
custom_prompt: formData.get('custom_prompt') || null,
default_language: formData.get('default_language') || null,
visibility: formData.get('visibility') || null,
default_min_speakers: formData.get('default_min_speakers') ? parseInt(formData.get('default_min_speakers')) : null,
default_max_speakers: formData.get('default_max_speakers') ? parseInt(formData.get('default_max_speakers')) : null
};
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
let url = '/tool/speakr/api/tags';
let method = 'POST';
if (isEditingTag) {
url += `/${formData.get('tag_id')}`;
method = 'PUT';
}
const response = await fetch(url, {
method: method,
headers: headers,
body: JSON.stringify(tagData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
const savedTag = await response.json();
if (isEditingTag) {
// Update existing tag in local array
const index = tags.findIndex(t => t.id === savedTag.id);
if (index !== -1) {
tags[index] = savedTag;
}
showToast(`Tag "${savedTag.name}" updated successfully`);
} else {
// Add new tag to local array
tags.push(savedTag);
showToast(`Tag "${savedTag.name}" created successfully`);
}
displayTags();
closeTagModal();
} catch (error) {
console.error('Error saving tag:', error);
showToast(`Failed to save tag: ${error.message}`, 'error');
}
});
// AJAX form submission for Account Information
const accountInfoForm = document.getElementById('accountInfoForm');
const canManageSystemConfigs = {{ current_user.is_admin | tojson }};
let currentConfigScene = String(localStorage.getItem('selectedScene') || 'cg').trim().toLowerCase() === 'yl' ? 'yl' : 'cg';
const accountSceneCgButton = document.getElementById('accountSceneCg');
const accountSceneYlButton = document.getElementById('accountSceneYl');
let textareacot = ''
const textarea = document.querySelector('textarea.hot-textarea');
if (textarea) {
textarea.addEventListener('input', function (e) {
const currentValue = e.target.value;
textareacot = currentValue
console.log('textarea值已更新', currentValue);
});
}
async function updataBaseHot(params) {
if (!textarea || !textareacot.trim()) {
return;
}
const response = await fetch(`/tool/speakr/api/hotwords`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
hotwords: textareacot,
scene: currentConfigScene,
}),
});
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to create share link");
// console.log(data.summary, "接收到回参");
}
async function getBaseHot(params) {
const response = await fetch(
`/tool/speakr/api/hotwords?scene=${currentConfigScene}`,
{
method: "GET",
}
);
const data = await response.json();
if (response.ok) {
console.log(data, 'data');
updateTextareaValue(data.text_format)
}
}
let textareahandlecot = ''
const textareahandle = document.querySelector('textarea.handle-textarea');
if (textareahandle) {
textareahandle.addEventListener('input', function (e) {
const currentValue = e.target.value;
textareahandlecot = currentValue
});
}
function normalizeAccountScene(scene) {
return String(scene || 'cg').trim().toLowerCase() === 'yl' ? 'yl' : 'cg';
}
function getStandardWordMappingsConfigKey() {
return currentConfigScene === 'yl' ? 'standard_word_mappings_yl' : 'standard_word_mappings';
}
function refreshAccountSceneButtons() {
if (accountSceneCgButton) {
accountSceneCgButton.classList.toggle('bg-[var(--bg-button)]', currentConfigScene === 'cg');
accountSceneCgButton.classList.toggle('text-[var(--text-button)]', currentConfigScene === 'cg');
accountSceneCgButton.classList.toggle('border-[var(--bg-button)]', currentConfigScene === 'cg');
}
if (accountSceneYlButton) {
accountSceneYlButton.classList.toggle('bg-[var(--bg-button)]', currentConfigScene === 'yl');
accountSceneYlButton.classList.toggle('text-[var(--text-button)]', currentConfigScene === 'yl');
accountSceneYlButton.classList.toggle('border-[var(--bg-button)]', currentConfigScene === 'yl');
}
}
function setAccountConfigScene(scene) {
const nextScene = normalizeAccountScene(scene);
if (currentConfigScene === nextScene) {
refreshAccountSceneButtons();
return;
}
currentConfigScene = nextScene;
localStorage.setItem('account_config_scene', currentConfigScene);
refreshAccountSceneButtons();
if (canManageSystemConfigs) {
getBaseHot();
getStandardWordMappingsConfig();
}
}
async function updataBasehandle(params) {
if (!textareahandle) {
return;
}
const response = await fetch(`/tool/speakr/api/system-configs/end_phrase_patterns`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config_value: textareahandlecot,
}),
});
const data = await response.json();
if (!response.ok)
throw new Error(data.error || "Failed to create share link");
// console.log(data.summary, "接收到回参");
}
async function getBasehandle(params) {
const response = await fetch(
`/tool/speakr/api/system-configs/end_phrase_patterns`,
{
method: "GET",
}
);
const data = await response.json();
if (response.ok) {
let res = data?.config?.config_value || ''
updateTextareahandleValue(res)
}
}
const standardWordMappingsList = document.getElementById('standardWordMappingsList');
const standardWordMappingsEmpty = document.getElementById('standardWordMappingsEmpty');
const standardWordMappingsCount = document.getElementById('standardWordMappingsCount');
const standardWordImportPanel = document.getElementById('standardWordImportPanel');
const standardWordMappingsBulkInput = document.getElementById('standardWordMappingsBulkInput');
const addStandardWordMappingRowButton = document.getElementById('addStandardWordMappingRow');
const toggleStandardWordImportPanelButton = document.getElementById('toggleStandardWordImportPanel');
const closeStandardWordImportPanelButton = document.getElementById('closeStandardWordImportPanel');
const appendStandardWordMappingsFromBulkButton = document.getElementById('appendStandardWordMappingsFromBulk');
const replaceStandardWordMappingsFromBulkButton = document.getElementById('replaceStandardWordMappingsFromBulk');
const clearStandardWordMappingsBulkInputButton = document.getElementById('clearStandardWordMappingsBulkInput');
const copyStandardWordMappingsTextButton = document.getElementById('copyStandardWordMappingsText');
const clearAllStandardWordMappingsButton = document.getElementById('clearAllStandardWordMappings');
let draggedStandardWordMappingRow = null;
function splitStandardWordVariants(rawValue) {
return String(rawValue || '')
.split(/[\s、,]+/)
.map(item => item.trim())
.filter(Boolean);
}
function normalizeStandardWordMappings(mappings) {
if (!Array.isArray(mappings)) {
return [];
}
return mappings
.map((mapping) => {
const standard = String(
mapping?.standard ?? mapping?.standard_word ?? ''
).trim();
const variants = Array.isArray(mapping?.variants)
? mapping.variants
: splitStandardWordVariants(
mapping?.variants ?? mapping?.common_words ?? ''
);
const deduplicatedVariants = [...new Set(
variants
.map((variant) => String(variant || '').trim())
.filter((variant) => variant && variant !== standard)
)];
if (!standard || !deduplicatedVariants.length) {
return null;
}
return {
standard,
variants: deduplicatedVariants
};
})
.filter(Boolean);
}
function parseStandardWordMappingsConfig(rawValue) {
if (!rawValue || typeof rawValue !== 'string') {
return [];
}
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return [];
}
if (trimmedValue.startsWith('[') || trimmedValue.startsWith('{')) {
try {
const parsed = JSON.parse(trimmedValue);
if (Array.isArray(parsed)) {
return normalizeStandardWordMappings(parsed);
}
if (parsed && typeof parsed === 'object') {
return normalizeStandardWordMappings([parsed]);
}
} catch (error) {
console.warn('解析标准词映射 JSON 失败,将尝试按文本格式解析', error);
}
}
const mappings = trimmedValue
.split('\n')
.map((line) => {
const normalizedLine = line.trim();
if (!normalizedLine) {
return null;
}
const separator = normalizedLine.includes('')
? ''
: normalizedLine.includes(':')
? ':'
: null;
if (!separator) {
return null;
}
const separatorIndex = normalizedLine.indexOf(separator);
const standard = normalizedLine.slice(0, separatorIndex).trim();
const variantsValue = normalizedLine.slice(separatorIndex + 1).trim();
return {
standard,
variants: splitStandardWordVariants(variantsValue)
};
})
.filter(Boolean);
return normalizeStandardWordMappings(mappings);
}
function setStandardWordImportPanelVisible(visible) {
if (!standardWordImportPanel) {
return;
}
standardWordImportPanel.classList.toggle('hidden', !visible);
}
function updateStandardWordMappingsEmptyState() {
if (!standardWordMappingsList || !standardWordMappingsEmpty || !standardWordMappingsCount) {
return;
}
const rows = standardWordMappingsList.querySelectorAll('.standard-word-mapping-row');
const hasRows = rows.length > 0;
standardWordMappingsEmpty.classList.toggle('hidden', hasRows);
standardWordMappingsCount.textContent = `${rows.length}`;
}
function refreshStandardWordMappingRowState() {
if (!standardWordMappingsList) {
return;
}
const rows = Array.from(
standardWordMappingsList.querySelectorAll('.standard-word-mapping-row')
);
rows.forEach((row, index) => {
const orderBadge = row.querySelector('.standard-word-row-order');
const moveUpButton = row.querySelector('.move-standard-word-mapping-up');
const moveDownButton = row.querySelector('.move-standard-word-mapping-down');
if (orderBadge) {
orderBadge.textContent = `#${index + 1}`;
}
if (moveUpButton) {
moveUpButton.disabled = index === 0;
moveUpButton.classList.toggle('opacity-50', index === 0);
moveUpButton.classList.toggle('cursor-not-allowed', index === 0);
}
if (moveDownButton) {
const isLast = index === rows.length - 1;
moveDownButton.disabled = isLast;
moveDownButton.classList.toggle('opacity-50', isLast);
moveDownButton.classList.toggle('cursor-not-allowed', isLast);
}
});
updateStandardWordMappingsEmptyState();
}
function moveStandardWordMappingRow(row, direction) {
if (!row || !standardWordMappingsList) {
return;
}
if (direction < 0 && row.previousElementSibling) {
standardWordMappingsList.insertBefore(row, row.previousElementSibling);
} else if (direction > 0 && row.nextElementSibling) {
standardWordMappingsList.insertBefore(row.nextElementSibling, row);
}
refreshStandardWordMappingRowState();
}
function serializeStandardWordMappingsToText(mappings) {
return normalizeStandardWordMappings(mappings)
.map((mapping) => `${mapping.standard}${mapping.variants.join('、')}`)
.join('\n');
}
async function copyStandardWordMappingsText() {
const textValue = serializeStandardWordMappingsToText(collectStandardWordMappings());
if (!textValue) {
showToast('暂无可复制的标准词映射', 'error');
return;
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(textValue);
} else {
const tempTextarea = document.createElement('textarea');
tempTextarea.value = textValue;
document.body.appendChild(tempTextarea);
tempTextarea.select();
document.execCommand('copy');
tempTextarea.remove();
}
showToast('标准词映射文本已复制', 'success');
} catch (error) {
console.error('复制标准词映射失败', error);
showToast('复制失败,请手动复制', 'error');
}
}
function clearAllStandardWordMappings() {
if (!standardWordMappingsList) {
return;
}
const rows = standardWordMappingsList.querySelectorAll('.standard-word-mapping-row');
if (!rows.length) {
showToast('暂无可删除的标准词映射', 'error');
return;
}
const confirmed = window.confirm(
`确定要批量删除全部 ${rows.length} 条标准词映射吗?保存设置后将生效。`
);
if (!confirmed) {
return;
}
renderStandardWordMappings([]);
showToast('已清空全部标准词映射,请保存设置以生效', 'success');
}
function importStandardWordMappings(mode = 'append') {
const importedMappings = parseStandardWordMappingsConfig(
standardWordMappingsBulkInput?.value || ''
);
if (!importedMappings.length) {
showToast('未解析到有效映射,请检查导入格式', 'error');
return;
}
const currentMappings = mode === 'replace'
? []
: collectStandardWordMappings();
renderStandardWordMappings([...currentMappings, ...importedMappings]);
setStandardWordImportPanelVisible(false);
showToast(
mode === 'replace'
? `已覆盖导入 ${importedMappings.length} 条映射`
: `已追加导入 ${importedMappings.length} 条映射`,
'success'
);
}
function createStandardWordMappingRow(mapping = {}) {
const row = document.createElement('div');
row.className =
'standard-word-mapping-row grid grid-cols-1 md:grid-cols-[110px_minmax(0,1fr)_minmax(0,2fr)_150px] gap-3 items-start rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] p-4';
row.innerHTML = `
<div>
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2 md:hidden">排序</label>
<div class="flex items-center gap-2 md:flex-col md:items-start">
<button
type="button"
class="drag-standard-word-mapping inline-flex items-center justify-center w-9 h-9 rounded-md border border-[var(--border-primary)] text-[var(--text-muted)] cursor-grab active:cursor-grabbing"
title="拖拽排序"
>
<i class="fas fa-grip-vertical"></i>
</button>
<span
class="standard-word-row-order inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border-primary)]"
>
#1
</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2">标准词</label>
<input
type="text"
class="standard-word-standard-input block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]"
placeholder="例如OpenAI"
>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2">常用词</label>
<input
type="text"
class="standard-word-variants-input block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]"
placeholder="例如欧喷AI、欧彭AI 或 欧喷AI 欧彭AI"
>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2 md:hidden">操作</label>
<div class="flex items-center gap-2 md:flex-col md:items-stretch">
<button
type="button"
class="move-standard-word-mapping-up inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
title="上移"
>
<i class="fas fa-arrow-up"></i>
</button>
<button
type="button"
class="move-standard-word-mapping-down inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
title="下移"
>
<i class="fas fa-arrow-down"></i>
</button>
<button
type="button"
class="remove-standard-word-mapping inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:text-[var(--text-danger)] hover:border-[var(--text-danger)] transition-colors duration-200"
>
<i class="fas fa-trash-alt md:mr-0 mr-2"></i><span class="md:hidden">删除</span>
</button>
</div>
</div>
`;
row.querySelector('.standard-word-standard-input').value = mapping.standard || '';
row.querySelector('.standard-word-variants-input').value = Array.isArray(mapping.variants)
? mapping.variants.join('、')
: '';
row.querySelector('.remove-standard-word-mapping').addEventListener('click', () => {
row.remove();
refreshStandardWordMappingRowState();
});
row.querySelector('.move-standard-word-mapping-up').addEventListener('click', () => {
moveStandardWordMappingRow(row, -1);
});
row.querySelector('.move-standard-word-mapping-down').addEventListener('click', () => {
moveStandardWordMappingRow(row, 1);
});
const dragHandle = row.querySelector('.drag-standard-word-mapping');
dragHandle.draggable = true;
dragHandle.addEventListener('dragstart', (event) => {
draggedStandardWordMappingRow = row;
row.classList.add('opacity-60', 'ring-2', 'ring-[var(--border-focus)]');
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', mapping.standard || '');
}
});
dragHandle.addEventListener('dragend', () => {
draggedStandardWordMappingRow = null;
row.classList.remove('opacity-60', 'ring-2', 'ring-[var(--border-focus)]');
refreshStandardWordMappingRowState();
});
row.addEventListener('dragover', (event) => {
if (!draggedStandardWordMappingRow || draggedStandardWordMappingRow === row) {
return;
}
event.preventDefault();
const rect = row.getBoundingClientRect();
const shouldInsertBefore = event.clientY < rect.top + rect.height / 2;
if (shouldInsertBefore) {
standardWordMappingsList.insertBefore(draggedStandardWordMappingRow, row);
} else {
standardWordMappingsList.insertBefore(
draggedStandardWordMappingRow,
row.nextElementSibling
);
}
});
return row;
}
function renderStandardWordMappings(mappings = []) {
if (!standardWordMappingsList) {
return;
}
standardWordMappingsList.innerHTML = '';
const normalizedMappings = normalizeStandardWordMappings(mappings);
normalizedMappings.forEach((mapping) => {
standardWordMappingsList.appendChild(createStandardWordMappingRow(mapping));
});
refreshStandardWordMappingRowState();
}
function collectStandardWordMappings() {
if (!standardWordMappingsList) {
return [];
}
const mappings = Array.from(
standardWordMappingsList.querySelectorAll('.standard-word-mapping-row')
).map((row) => ({
standard: row.querySelector('.standard-word-standard-input')?.value || '',
variants: splitStandardWordVariants(
row.querySelector('.standard-word-variants-input')?.value || ''
)
}));
return normalizeStandardWordMappings(mappings);
}
async function updateStandardWordMappingsConfig() {
if (!standardWordMappingsList) {
return;
}
const mappings = collectStandardWordMappings();
const response = await fetch(`/tool/speakr/api/system-configs/${getStandardWordMappingsConfigKey()}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config_value: JSON.stringify(mappings),
description: "实时转录后处理中用于纠错后标准化替换的标准词映射"
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to update standard word mappings");
}
}
async function getStandardWordMappingsConfig() {
if (!standardWordMappingsList) {
return;
}
const response = await fetch(`/tool/speakr/api/system-configs/${getStandardWordMappingsConfigKey()}`, {
method: "GET",
});
const data = await response.json();
if (response.ok) {
renderStandardWordMappings(
parseStandardWordMappingsConfig(data?.config?.config_value || '')
);
return;
}
renderStandardWordMappings([]);
}
if (toggleStandardWordImportPanelButton) {
toggleStandardWordImportPanelButton.addEventListener('click', () => {
const willShow = standardWordImportPanel?.classList.contains('hidden');
setStandardWordImportPanelVisible(willShow);
if (willShow) {
standardWordMappingsBulkInput?.focus();
}
});
}
if (closeStandardWordImportPanelButton) {
closeStandardWordImportPanelButton.addEventListener('click', () => {
setStandardWordImportPanelVisible(false);
});
}
if (appendStandardWordMappingsFromBulkButton) {
appendStandardWordMappingsFromBulkButton.addEventListener('click', () => {
importStandardWordMappings('append');
});
}
if (replaceStandardWordMappingsFromBulkButton) {
replaceStandardWordMappingsFromBulkButton.addEventListener('click', () => {
importStandardWordMappings('replace');
});
}
if (clearStandardWordMappingsBulkInputButton) {
clearStandardWordMappingsBulkInputButton.addEventListener('click', () => {
if (standardWordMappingsBulkInput) {
standardWordMappingsBulkInput.value = '';
standardWordMappingsBulkInput.focus();
}
});
}
if (copyStandardWordMappingsTextButton) {
copyStandardWordMappingsTextButton.addEventListener('click', () => {
copyStandardWordMappingsText();
});
}
if (clearAllStandardWordMappingsButton) {
clearAllStandardWordMappingsButton.addEventListener('click', () => {
clearAllStandardWordMappings();
});
}
if (addStandardWordMappingRowButton) {
addStandardWordMappingRowButton.addEventListener('click', () => {
standardWordMappingsList?.appendChild(createStandardWordMappingRow());
refreshStandardWordMappingRowState();
});
}
const openBtn = document.getElementById('openPromptModal');
const modal = document.getElementById('promptModal');
const cancelBtnprompt = document.getElementById('cancelPrompt');
const confirmBtn = document.getElementById('confirmPrompt');
const promptListEl = document.getElementById('promptList');
// 保存当前选中的 prompt id
let selectedPromptId = localStorage.getItem('selected_prompt_id') || null;
// 打开弹框
openBtn.onclick = async () => {
modal.classList.remove('hidden');
await loadPromptList();
};
// 关闭弹框
cancelBtnprompt.onclick = () => modal.classList.add('hidden');
// 确认选择
confirmBtn.onclick = () => {
const checkedRadio = document.querySelector('input[name="prompt_radio"]:checked');
if (checkedRadio) {
selectedPromptId = checkedRadio.value;
localStorage.setItem('selected_prompt_id', selectedPromptId);
// 显示选中的名称到 textarea
const selectedName = checkedRadio.getAttribute('data-name');
document.getElementById('promptTextarea').value = selectedName;
}
modal.classList.add('hidden');
};
// 加载 Prompt 列表
async function loadPromptList() {
promptListEl.innerHTML = '<p class="text-sm text-gray-500">加载中...</p>';
try {
const response = 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 response.json();
// 渲染列表
promptListEl.innerHTML = '';
data.prompt_list.forEach(item => {
const row = document.createElement('div');
row.className = "grid grid-cols-[50px_350px] px-2 py-2 items-start gap-2 bg-[#1f2937] rounded";
row.innerHTML = `
<div class="flex justify-center items-start pt-1">
<input type="radio"
name="prompt_radio"
value="${item.id}"
data-name="${item.name}"
${selectedPromptId == item.id ? 'checked' : ''}>
</div>
<div class="whitespace-normal break-words leading-5">
${item.name}
</div>
`;
promptList.appendChild(row);
});
} catch (err) {
promptListEl.innerHTML = '<p class="text-red-500 text-sm">加载失败</p>';
}
}
if (accountSceneCgButton) {
accountSceneCgButton.addEventListener('click', () => setAccountConfigScene('cg'));
}
if (accountSceneYlButton) {
accountSceneYlButton.addEventListener('click', () => setAccountConfigScene('yl'));
}
refreshAccountSceneButtons();
if (canManageSystemConfigs) {
getBaseHot()
getBasehandle()
getStandardWordMappingsConfig()
}
// getBasePrompt()
function updateTextareaValue(newValue) {
if (!textarea) {
return;
}
// 设置新值
textarea.value = newValue;
// 手动触发input事件让监听器感知到值变化
textarea.dispatchEvent(new Event('input'));
}
function updateTextareahandleValue(newValue) {
if (!textareahandle) {
return;
}
// 设置新值
textareahandle.value = newValue;
// 手动触发input事件让监听器感知到值变化
textareahandle.dispatchEvent(new Event('input'));
}
function updatePromptValue(newValue) {
// 设置新值
textareaHandelPrompt.value = newValue;
// 手动触发input事件让监听器感知到值变化
textareaHandelPrompt.dispatchEvent(new Event('input'));
}
if (accountInfoForm) {
accountInfoForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(accountInfoForm);
console.log(formData, 'formData');
try {
if (canManageSystemConfigs) {
await Promise.all([
updataBaseHot(),
updataBasehandle(),
updateStandardWordMappingsConfig()
]);
}
const response = await fetch('/tool/speakr/account', {
method: 'POST',
body: formData
});
if (response.ok) {
if (canManageSystemConfigs) {
getBaseHot()
getBasehandle()
getStandardWordMappingsConfig()
}
showToast('Account information updated successfully', 'success');
} else {
const text = await response.text();
showToast('Failed to update account information', 'error');
}
} catch (error) {
showToast('Network error: ' + error.message, 'error');
}
});
}
// AJAX form submission for Custom Prompts
const customPromptsForm = document.getElementById('customPromptsForm');
if (customPromptsForm) {
customPromptsForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(customPromptsForm);
try {
const response = await fetch('/tool/speakr/account', {
method: 'POST',
body: formData
});
if (response.ok) {
showToast('Custom prompt saved successfully', 'success');
} else {
const text = await response.text();
showToast('Failed to save custom prompt', 'error');
}
} catch (error) {
showToast('Network error: ' + error.message, 'error');
}
});
}
// Check for stored tab preference (from language change reload)
const storedTab = localStorage.getItem('activeAccountTab');
if (storedTab) {
localStorage.removeItem('activeAccountTab'); // Clean up after use
}
// Check for URL hash and show appropriate tab
const urlHash = window.location.hash.substring(1); // Remove the '#' symbol
if (urlHash === 'prompts') {
showPromptsTab();
} else if (urlHash === 'tags') {
showTagsTab();
} else if (urlHash === 'shares') {
showSharesTab();
} else if (urlHash === 'shares') {
showSharesTab();
} else if (urlHash === 'voiceprint') {
showVoiceprint();
} else if (storedTab) {
// Restore previously active tab after language change
if (storedTab === 'prompts') {
showPromptsTab();
} else if (storedTab === 'tags') {
showTagsTab();
} else if (storedTab === 'shares') {
showSharesTab();
} else if (storedTab === 'about') {
showAboutTab();
} else if (storedTab === 'voiceprint') {
showVoiceprint();
} else {
showAccountTab();
}
} else {
// Default to account tab
showAccountTab();
}
// Track original UI language to detect changes
let currentUILanguage = document.getElementById('ui_language').value;
const originalUILanguage = currentUILanguage;
// Add immediate language change listener to the UI language dropdown
const uiLanguageSelect = document.getElementById('ui_language');
if (uiLanguageSelect) {
uiLanguageSelect.addEventListener('change', async function (e) {
const newLanguage = e.target.value;
if (newLanguage !== currentUILanguage) {
await changeLanguageImmediately(newLanguage);
// Update the current language tracker
currentUILanguage = newLanguage;
}
});
}
// Add event listener to the account info form
document.getElementById('accountInfoForm').addEventListener('submit', function (e) {
const newUILanguage = document.getElementById('ui_language').value;
if (newUILanguage !== originalUILanguage) {
// Language has changed, store flag to reload main app
localStorage.setItem('ui_language_changed', 'true');
localStorage.setItem('preferredLanguage', newUILanguage);
}
});
// Template Management Functions
let templates = [];
let currentTemplate = null;
let isEditingTemplate = false;
async function loadTemplates() {
try {
const response = await fetch('/tool/speakr/api/transcript-templates');
if (!response.ok) {
throw new Error('Failed to load templates');
}
templates = await response.json();
// Create default templates if none exist
if (templates.length === 0) {
document.getElementById('createDefaultTemplatesBtn').style.display = 'block';
} else {
document.getElementById('createDefaultTemplatesBtn').style.display = 'none';
}
renderTemplatesList();
} catch (error) {
console.error('Error loading templates:', error);
showToast('Failed to load templates', 'error');
}
}
function renderTemplatesList() {
const templatesList = document.getElementById('templatesList');
templatesList.innerHTML = '';
if (templates.length === 0) {
templatesList.innerHTML = '<p class="text-sm text-[var(--text-muted)] text-center py-4">暂无模板</p>';
return;
}
templates.forEach(template => {
const templateItem = document.createElement('div');
templateItem.className = 'p-3 bg-[var(--bg-primary)] rounded-md hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors border border-[var(--border-primary)]';
if (template.is_default) {
templateItem.className += ' ring-2 ring-[var(--ring-focus)]';
}
templateItem.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="text-sm font-medium text-[var(--text-primary)]">${template.name}</h4>
${template.description ? `<p class="text-xs text-[var(--text-muted)] mt-1">${template.description}</p>` : ''}
${template.is_default ? '<span class="text-xs text-[var(--text-accent)] mt-1"><i class="fas fa-star mr-1"></i>Default</span>' : ''}
</div>
</div>
`;
templateItem.addEventListener('click', () => selectTemplate(template));
templatesList.appendChild(templateItem);
});
}
function selectTemplate(template) {
currentTemplate = template;
isEditingTemplate = true;
// Show editor, hide empty state
document.getElementById('templateEditor').classList.remove('hidden');
document.getElementById('templateEmptyState').classList.add('hidden');
// Populate form
document.getElementById('templateName').value = template.name;
document.getElementById('templateDescription').value = template.description || '';
document.getElementById('templateContent').value = template.template;
document.getElementById('templateIsDefault').checked = template.is_default;
// Show delete button
document.getElementById('deleteTemplateBtn').classList.remove('hidden');
}
function createNewTemplate() {
currentTemplate = null;
isEditingTemplate = false;
// Show editor, hide empty state
document.getElementById('templateEditor').classList.remove('hidden');
document.getElementById('templateEmptyState').classList.add('hidden');
// Clear form
document.getElementById('templateForm').reset();
// Hide delete button
document.getElementById('deleteTemplateBtn').classList.add('hidden');
}
document.getElementById('createTemplateBtn')?.addEventListener('click', createNewTemplate);
document.getElementById('cancelTemplateBtn')?.addEventListener('click', () => {
// Hide editor, show empty state
document.getElementById('templateEditor').classList.add('hidden');
document.getElementById('templateEmptyState').classList.remove('hidden');
currentTemplate = null;
isEditingTemplate = false;
});
document.getElementById('templateForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const templateData = {
name: document.getElementById('templateName').value,
description: document.getElementById('templateDescription').value,
template: document.getElementById('templateContent').value,
is_default: document.getElementById('templateIsDefault').checked
};
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const url = isEditingTemplate ? `/tool/speakr/api/transcript-templates/${currentTemplate.id}` : '/tool/speakr/api/transcript-templates';
const method = isEditingTemplate ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(templateData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save template');
}
const savedTemplate = await response.json();
if (isEditingTemplate) {
// Update existing template
const index = templates.findIndex(t => t.id === savedTemplate.id);
if (index !== -1) {
templates[index] = savedTemplate;
}
} else {
// Add new template
templates.push(savedTemplate);
}
// Update default status for other templates if needed
if (savedTemplate.is_default) {
templates.forEach(t => {
if (t.id !== savedTemplate.id) {
t.is_default = false;
}
});
}
renderTemplatesList();
showToast(isEditingTemplate ? 'Template updated successfully' : 'Template created successfully', 'success');
// Hide editor
document.getElementById('templateEditor').classList.add('hidden');
document.getElementById('templateEmptyState').classList.remove('hidden');
currentTemplate = null;
isEditingTemplate = false;
} catch (error) {
console.error('Error saving template:', error);
showToast(error.message || 'Failed to save template', 'error');
}
});
document.getElementById('deleteTemplateBtn')?.addEventListener('click', async () => {
if (!currentTemplate || !confirm('Are you sure you want to delete this template?')) {
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/tool/speakr/api/transcript-templates/${currentTemplate.id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
throw new Error('Failed to delete template');
}
// Remove from local array
templates = templates.filter(t => t.id !== currentTemplate.id);
renderTemplatesList();
// Hide editor
document.getElementById('templateEditor').classList.add('hidden');
document.getElementById('templateEmptyState').classList.remove('hidden');
currentTemplate = null;
isEditingTemplate = false;
showToast('Template deleted successfully', 'success');
} catch (error) {
console.error('Error deleting template:', error);
showToast('Failed to delete template', 'error');
}
});
document.getElementById('createDefaultTemplatesBtn')?.addEventListener('click', async () => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/tool/speakr/api/transcript-templates/create-defaults', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
throw new Error('Failed to create default templates');
}
const result = await response.json();
templates = result.templates;
renderTemplatesList();
showToast('Default templates created successfully', 'success');
} catch (error) {
console.error('Error creating default templates:', error);
showToast('Failed to create default templates', 'error');
}
});
function showToast(message, type = 'info') {
// Simple toast notification
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-2 rounded-md text-white z-50 ${type === 'success' ? 'bg-green-600' :
type === 'error' ? 'bg-red-600' :
'bg-blue-600'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
});
</script>
</body>
<style scoped>
:root {
--text-primarys: #333;
--btn-primarys: #4f46e5;
--btn-dangers: #dc2626;
--bg-modals: rgba(0, 0, 0, 0.5);
/* --border-color: #e5e7eb; */
}
body {
font-family: Arial, sans-serif;
/* margin: 20px; */
/* background: #f9f9f9; */
}
.btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
color: white;
}
.btn-primary {
background: var(--btn-primarys);
}
.btn-danger {
background: var(--btn-dangers);
}
.btn-icon {
background: none;
padding: 4px;
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid var(--border-colors);
border-radius: 6px;
overflow: hidden;
}
th,
td {
text-align: left;
padding: 12px;
border-bottom: 1px solid var(--border-colors);
color: #000;
}
th {
background: #f3f4f6;
color: #000;
}
.top-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-modals);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
width: 400px;
}
.modal-header {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
}
.modal-footer {
text-align: right;
margin-top: 16px;
}
input,
textarea {
width: 100%;
padding: 6px;
margin-bottom: 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
}
audio {
width: 100%;
margin-bottom: 12px;
}
/* ===== 弹框整体样式优化 ===== */
#voiceModal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
justify-content: center;
align-items: center;
z-index: 1000;
font-family: "Microsoft YaHei", sans-serif;
}
#voiceModal .modal-content {
background: #fff;
width: 420px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 20px 24px;
color: #000;
box-sizing: border-box;
}
/* ===== 标题样式 ===== */
#voiceModal .modal-header {
font-size: 18px;
font-weight: 600;
color: #000;
margin-bottom: 20px;
}
/* ===== 标签标题 ===== */
#voiceModal .label-tit {
font-size: 14px;
font-weight: 500;
color: #000;
margin-bottom: 6px;
}
/* ===== 输入框、下拉框、文本域 ===== */
#voiceModal input[type="file"],
#voiceModal input[type="text"],
#voiceModal textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
color: #000;
outline: none;
transition: border-color 0.2s;
}
#voiceModal input[type="file"]:hover,
#voiceModal input[type="text"]:hover,
#voiceModal textarea:hover {
border-color: #409eff;
}
#voiceModal input[type="file"]:focus,
#voiceModal input[type="text"]:focus,
#voiceModal textarea:focus {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
/* ===== 提示文字 ===== */
#voiceModal .tips {
font-size: 12px;
color: #666;
margin-top: 4px;
margin-bottom: 16px;
line-height: 1.4;
}
/* ===== 按钮样式 ===== */
#voiceModal .modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
#voiceModal .btn {
padding: 6px 14px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
color: #000;
}
#voiceModal .btn:hover {
border-color: #bfbfbf;
background: #f5f5f5;
}
#voiceModal .btn-primary {
background: #1677ff;
color: #fff !important;
border-color: #1677ff;
}
#voiceModal .btn-primary:hover {
background: #409eff;
}
/* ===== 录音按钮样式(普通与录音中状态) ===== */
#voiceModal button[type="button"][onclick^="startRecording"] {
margin-top: 6px;
margin-bottom: 10px;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
border: 1px solid #d9d9d9;
background: #fff;
color: #000;
cursor: pointer;
transition: all 0.2s;
}
#voiceModal button[type="button"][onclick^="startRecording"]:hover {
border-color: #409eff;
color: #409eff;
}
/* 录音中状态 */
#voiceModal .btn-recording {
background: #fff !important;
color: #dc2626 !important;
border: 1px solid #dc2626 !important;
font-weight: 600;
}
#voiceModal .btn-recording::before {
content: "⏹ ";
color: #dc2626;
}
/* ===== 音频播放控件 ===== */
#voiceModal audio {
width: 100%;
margin-top: 10px;
margin-bottom: 10px;
}
/* ===== 输入框间距 ===== */
#voiceModal input[type="text"],
#voiceModal textarea,
#voiceModal input[type="file"] {
margin-bottom: 12px;
}
/* 录音中状态 */
.btn-recording {
background: #fff !important;
color: #dc2626 !important;
border: 1px solid #dc2626;
font-weight: bold;
}
/* 录音时的红色方块动画效果(可选) */
.btn-recording::before {
content: "⏹ ";
color: #dc2626;
}
/* 普通状态下的麦克风图标 */
button#recordBtn::before {
content: "🎤 ";
}
.file-box,
.record-box {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
/* 清空按钮样式 */
.btn-clear {
padding: 4px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
color: #666;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.btn-clear:hover {
border-color: #ff4d4f;
color: #ff4d4f;
}
</style>
</html>