Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)

This commit is contained in:
InnovA AI
2026-03-16 21:47:37 +00:00
commit 42772a31ed
365 changed files with 103572 additions and 0 deletions

969
templates/inquire.html Normal file
View File

@@ -0,0 +1,969 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>Inquire Mode - DictIA</title>
<!-- All dependencies bundled locally for offline support -->
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<!-- All dependencies bundled locally for offline support -->
<script src="{{ url_for('static', filename='vendor/js/vue.global.js') }}"></script>
<!-- All dependencies bundled locally for offline support -->
<script src="{{ url_for('static', filename='vendor/js/marked.min.js') }}"></script>
<!-- All dependencies bundled locally for offline support -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="{{ url_for('static', filename='js/i18n.js') }}"></script>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='img/icon-32x32.png') }}">
<!-- Loading overlay to prevent FOUC -->
{% include 'includes/loading_overlay.html' %}
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: 'var(--bg-primary)',
secondary: 'var(--bg-secondary)',
accent: 'var(--bg-accent)'
}
}
}
}
</script>
<style>
/* Custom checkbox styling for better visual feedback */
.filter-checkbox {
appearance: none;
background-color: var(--bg-input);
border: 2px solid var(--border-secondary);
border-radius: 4px;
width: 16px;
height: 16px;
position: relative;
transition: all 0.2s ease;
}
.filter-checkbox:checked {
background-color: var(--text-accent);
border-color: var(--text-accent);
}
.filter-checkbox:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 10px;
font-weight: bold;
}
.filter-checkbox:focus {
outline: 2px solid var(--text-accent);
outline-offset: 2px;
}
.filter-checkbox:hover {
border-color: var(--text-accent);
}
/* Enhanced markdown styling for Inquire Mode chat messages */
.chat-message .prose h1,
.chat-message .prose h2,
.chat-message .prose h3,
.chat-message .prose h4 {
color: var(--text-primary);
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.75em;
line-height: 1.3;
}
.chat-message .prose h1 { font-size: 1.25em; }
.chat-message .prose h2 { font-size: 1.15em; }
.chat-message .prose h3 { font-size: 1.05em; }
.chat-message .prose p {
color: var(--text-primary);
margin-bottom: 1em;
line-height: 1.6;
}
.chat-message .prose ul,
.chat-message .prose ol {
color: var(--text-primary);
margin: 0.75em 0;
padding-left: 1.5em;
}
.chat-message .prose li {
color: var(--text-primary);
margin: 0.25em 0;
line-height: 1.6;
}
.chat-message .prose strong {
color: var(--text-primary);
font-weight: 700;
}
.chat-message .prose ul { list-style-type: disc; }
.chat-message .prose ol { list-style-type: decimal; }
.chat-message .prose code {
background-color: var(--bg-accent);
color: var(--text-accent);
padding: 0.15em 0.3em;
border-radius: 3px;
font-size: 0.9em;
}
</style>
</head>
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] transition-colors duration-300">
<div id="inquire-app" v-cloak class="h-full flex flex-col"
data-current-user-name="{{ (current_user.name or current_user.username) if current_user.is_authenticated else '' }}">
<!-- Header -->
<header class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] px-3 sm:px-4 py-3 flex items-center justify-between flex-shrink-0 z-50">
<!-- Left side: Menu and logo -->
<div class="flex items-center gap-2 sm:gap-4 min-w-0 flex-shrink">
<!-- Mobile Sidebar Toggle -->
<button @click="isMobileSidebarOpen = !isMobileSidebarOpen"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200 lg:hidden flex-shrink-0 flex items-center justify-center">
<i class="fas fa-bars text-lg"></i>
</button>
<!-- Logo and Title (clickable to go back) -->
<a href="/" class="flex items-center gap-2 sm:gap-3 min-w-0 hover:opacity-80 transition-opacity">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="w-6 h-6 sm:w-8 sm:h-8 flex-shrink-0">
<h1 class="text-lg sm:text-xl font-bold text-[var(--text-primary)] truncate">DictIA</h1>
<span class="text-xs sm:text-sm text-[var(--text-muted)] hidden sm:inline">• Inquire Mode</span>
</a>
</div>
<!-- Right side: User menu -->
<div class="flex items-center gap-2 sm:gap-3">
{% include 'components/token_budget_indicator.html' %}
<!-- New Recording Button -->
<a href="/"
class="px-3 py-1.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm flex items-center gap-2">
<i class="fas fa-plus"></i>
<span class="hidden sm:inline">${t('nav.newRecording')}</span>
</a>
<!-- User menu -->
{% if current_user.is_authenticated %}
<div class="relative">
<button @click="isUserMenuOpen = !isUserMenuOpen"
data-user-menu-toggle
class="flex items-center gap-1 sm:gap-2 p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
:title="t('admin.userMenu')">
<i class="fas fa-user-circle text-lg"></i>
<span class="hidden lg:inline text-sm">{{ (current_user.name or current_user.username) if current_user.is_authenticated else 'User' }}</span>
<i class="fas fa-chevron-down text-xs hidden sm:inline"></i>
</button>
<!-- User dropdown -->
<div v-if="isUserMenuOpen"
data-user-menu-dropdown
class="absolute right-0 mt-2 w-56 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-50">
<a href="/" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-home mr-2 w-4 text-center"></i><span v-text="t('nav.home')"></span>
</a>
<a href="/account" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-cog mr-2 w-4 text-center"></i><span v-text="t('nav.settings')"></span>
</a>
<a href="/account#tags" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-tags mr-2 w-4 text-center"></i><span v-text="t('help.tagManagement')"></span>
</a>
<button @click="openSharesList" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
<i class="fas fa-share-alt mr-2 w-4 text-center"></i><span v-text="t('modal.sharedTranscripts')"></span>
</button>
{% if current_user.is_admin or is_group_admin %}
<a href="/admin" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-{% if current_user.is_admin %}user-shield{% else %}users-cog{% endif %} mr-2 w-4 text-center"></i>
<span v-text="{% if current_user.is_admin %}t('admin.title'){% else %}t('nav.groupManagement'){% endif %}"></span>
</a>
{% endif %}
<div class="border-t border-[var(--border-primary)] my-1"></div>
<button @click="toggleDarkMode" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'" class="mr-2 w-4 text-center"></i>
<span v-text="isDarkMode ? t('nav.lightMode') : t('nav.darkMode')"></span>
</button>
<button @click="openColorSchemeModal" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
<i class="fas fa-palette mr-2 w-4 text-center"></i>
<span v-text="t('modal.colorScheme')"></span>
</button>
<div class="border-t border-[var(--border-primary)] my-1"></div>
<a href="/logout" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-sign-out-alt mr-2 w-4 text-center"></i><span v-text="t('nav.signOut')"></span>
</a>
</div>
</div>
{% endif %}
</div>
</header>
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden relative">
<!-- Mobile Sidebar Overlay -->
<div v-if="isMobileSidebarOpen"
@click="isMobileSidebarOpen = false"
class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"></div>
<!-- Left Panel: Filters -->
<div class="w-80 bg-[var(--bg-secondary)] border-r border-[var(--border-primary)] flex flex-col flex-shrink-0 transition-transform duration-300 ease-in-out z-50"
:class="[
'lg:relative lg:translate-x-0',
isMobileSidebarOpen ? 'fixed inset-y-0 left-0 translate-x-0' : 'fixed inset-y-0 left-0 -translate-x-full lg:translate-x-0'
]">
<!-- Mobile close button -->
<div class="lg:hidden flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
<h2 class="text-lg font-bold">${t('inquire.filters')}</h2>
<button @click="isMobileSidebarOpen = false"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar">
<div class="p-4 space-y-4">
<div class="flex items-center justify-between lg:block">
<h2 class="text-lg font-bold hidden lg:block">${t('inquire.filters')}</h2>
<button @click="clearInquireFilters" class="text-xs px-3 py-1 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors flex items-center gap-1 w-full lg:w-auto justify-center">
<i class="fas fa-times"></i> ${t('inquire.clearAll')}
</button>
</div>
<!-- Tag Filters -->
<div class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-3">
<h3 class="font-medium mb-2 flex items-center text-[var(--text-primary)]">
<i class="fas fa-tags mr-2 text-[var(--text-accent)]"></i>${t('inquire.tags')}
</h3>
<!-- Tag search input -->
<div class="mb-3">
<input type="text" v-model="tagSearchQuery" :placeholder="t('form.searchTags')"
class="w-full px-2 py-1 text-sm bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:border-[var(--border-focus)] focus:ring-1 focus:ring-[var(--border-focus)] transition-colors">
</div>
<div class="space-y-1 max-h-40 overflow-y-auto custom-scrollbar">
<label v-for="tag in filteredTags" :key="tag.id" class="flex items-center cursor-pointer hover:bg-[var(--bg-tertiary)] p-2 rounded transition-colors group">
<input type="checkbox" :value="tag.id" v-model="inquireFilters.selectedTags"
class="filter-checkbox">
<span class="ml-2 text-sm font-medium flex-1" :style="{color: tag.color}">${tag.name}</span>
<span class="text-xs px-1.5 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded-full">${tag.recording_count}</span>
</label>
</div>
</div>
<!-- Speaker Filters -->
<div class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-3">
<h3 class="font-medium mb-2 flex items-center text-[var(--text-primary)]">
<i class="fas fa-users mr-2 text-[var(--text-accent)]"></i>${t('inquire.speakers')}
</h3>
<div v-if="availableFilters.speakers.length > 0">
<!-- Speaker search input -->
<div class="mb-3">
<input type="text" v-model="speakerSearchQuery" :placeholder="t('form.searchSpeakers')"
class="w-full px-2 py-1 text-sm bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:border-[var(--border-focus)] focus:ring-1 focus:ring-[var(--border-focus)] transition-colors">
</div>
<div class="space-y-1 max-h-40 overflow-y-auto custom-scrollbar">
<label v-for="speaker in filteredSpeakers" :key="speaker" class="flex items-center cursor-pointer hover:bg-[var(--bg-tertiary)] p-2 rounded transition-colors group">
<input type="checkbox" :value="speaker" v-model="inquireFilters.selectedSpeakers"
class="filter-checkbox">
<span class="ml-2 text-sm text-[var(--text-primary)] flex-1">${speaker}</span>
</label>
</div>
</div>
<!-- No Speakers Message -->
<div v-if="availableFilters.speakers.length === 0" class="text-center py-3">
<i class="fas fa-users text-xl text-[var(--text-muted)] mb-2"></i>
<p class="text-sm text-[var(--text-muted)]">${t('inquire.noSpeakerData')}</p>
<p class="text-xs text-[var(--text-muted)] mt-1">${t('inquire.speakerRequirement')}</p>
</div>
</div>
<!-- Date Range Filters -->
<div class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-3">
<h3 class="font-medium mb-2 flex items-center text-[var(--text-primary)]">
<i class="fas fa-calendar mr-2 text-[var(--text-accent)]"></i>${t('inquire.dateRange')}
</h3>
<div class="space-y-2">
<div>
<label class="block text-xs text-[var(--text-muted)] mb-1 font-medium">${t('inquire.from')}</label>
<input type="date" v-model="inquireFilters.dateFrom" class="w-full px-2 py-1 text-sm bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:border-[var(--border-focus)] focus:ring-1 focus:ring-[var(--border-focus)] transition-colors">
</div>
<div>
<label class="block text-xs text-[var(--text-muted)] mb-1 font-medium">${t('inquire.to')}</label>
<input type="date" v-model="inquireFilters.dateTo" class="w-full px-2 py-1 text-sm bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:border-[var(--border-focus)] focus:ring-1 focus:ring-[var(--border-focus)] transition-colors">
</div>
</div>
</div>
<!-- Recording Filters - Commented Out -->
<!--
<div class="flex-1 flex flex-col px-6 pb-6 min-h-0">
<div class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-4 flex flex-col flex-1 min-h-0">
<h3 class="font-medium mb-3 flex items-center flex-shrink-0 text-[var(--text-primary)]">
<i class="fas fa-file-audio mr-2 text-[var(--text-accent)]"></i>Recordings
</h3>
<div class="space-y-1 flex-1 overflow-y-auto custom-scrollbar pr-2 min-h-0">
<label v-for="recording in availableFilters.recordings" :key="recording.id" class="flex items-center cursor-pointer hover:bg-[var(--bg-tertiary)] p-3 rounded-md transition-colors border border-transparent hover:border-[var(--border-secondary)]">
<input type="checkbox" :value="recording.id" v-model="inquireFilters.selectedRecordings" class="h-4 w-4 rounded border-[var(--border-secondary)] text-[var(--text-accent)] focus:ring-[var(--border-focus)] flex-shrink-0">
<div class="ml-3 flex-1 min-w-0">
<div class="text-sm font-medium truncate text-[var(--text-primary)]" :title="recording.title">${recording.title}</div>
<div v-if="recording.meeting_date" class="text-xs text-[var(--text-muted)] mt-1">${formatDate(recording.meeting_date)}</div>
</div>
</label>
</div>
</div>
-->
<!-- Active Filters Summary -->
<div v-if="hasActiveFilters" class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-3">
<h4 class="text-sm font-medium mb-2 text-[var(--text-muted)] flex items-center">
<i class="fas fa-filter mr-2"></i>${t('inquire.activeFilters')}
</h4>
<div class="space-y-1 text-xs">
<div v-if="inquireFilters.selectedTags.length" class="flex items-center px-2 py-1 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
<span><i class="fas fa-tags mr-1"></i>${inquireFilters.selectedTags.length} ${t('inquire.tagsCount')}</span>
</div>
<div v-if="inquireFilters.selectedSpeakers.length" class="flex items-center px-2 py-1 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
<span><i class="fas fa-users mr-1"></i>${inquireFilters.selectedSpeakers.length} ${t('inquire.speakersCount')}</span>
</div>
<div v-if="inquireFilters.dateFrom || inquireFilters.dateTo" class="flex items-center px-2 py-1 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
<span><i class="fas fa-calendar mr-1"></i>${t('inquire.dateRangeActive')}</span>
</div>
</div>
</div>
</div><!-- End of p-4 space-y-4 div -->
</div><!-- End of flex-1 overflow-y-auto div -->
</div><!-- End of w-80 sidebar div -->
<!-- Right Panel: Chat -->
<div class="flex-1 flex flex-col overflow-hidden"
:class="isMobileSidebarOpen ? 'hidden lg:flex' : 'flex'">
<!-- Chat Messages Area -->
<div ref="inquireMessagesRef" @scroll="handleScroll" class="flex-1 overflow-y-auto p-6 space-y-4 custom-scrollbar">
<!-- Welcome Message -->
<div v-if="inquireChatMessages.length === 0" class="text-center py-16">
<div class="mb-6 text-[var(--text-accent)]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-4">${t('inquire.askQuestions')}</h2>
<p class="text-[var(--text-muted)] max-w-lg mx-auto mb-6">
${t('inquire.selectFilters')}
</p>
<div class="bg-[var(--bg-tertiary)] rounded-xl p-4 max-w-md mx-auto">
<h4 class="font-medium mb-2">${t('inquire.exampleQuestions')}</h4>
<ul class="text-sm text-[var(--text-secondary)] space-y-1 text-left">
<li>${t('inquire.exampleQuestion1')}</li>
<li>${t('inquire.exampleQuestion2')}</li>
<li>${t('inquire.exampleQuestion3')}</li>
<li>${t('inquire.exampleQuestion4')}</li>
</ul>
</div>
</div>
<!-- Chat Messages -->
<div v-for="(message, index) in inquireChatMessages" :key="index"
:class="['flex', message.role === 'user' ? 'justify-end' : 'justify-start']">
<div :class="['max-w-3xl px-4 py-4 rounded-xl chat-message',
message.role === 'user'
? 'bg-[var(--bg-accent)] text-[var(--text-accent)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]']">
<div class="prose prose-sm max-w-none text-[var(--text-primary)]"
:class="message.role === 'user' ? 'prose-invert' : ''"
v-html="message.html || message.content">
</div>
</div>
</div>
<!-- Loading indicator with status -->
<div v-if="isInquireChatLoading" class="flex justify-start">
<div class="bg-[var(--bg-tertiary)] px-4 py-3 rounded-xl max-w-md">
<div class="flex items-center space-x-2">
<div class="animate-spin h-5 w-5 border-2 border-[var(--text-accent)] border-t-transparent rounded-full flex-shrink-0"></div>
<span class="text-sm text-[var(--text-muted)]" v-if="showProcessingStatus">${chatProcessingStatus}</span>
<span class="text-sm text-[var(--text-muted)]" v-else>Analyzing transcriptions...</span>
</div>
<!-- Progress steps indicator -->
<div v-if="showProcessingStatus" class="mt-2 flex space-x-1">
<div class="h-1 w-4 bg-[var(--text-accent)] rounded" :class="{ 'animate-pulse': chatProcessingStatus.includes('Analyzing') }"></div>
<div class="h-1 w-4 rounded" :class="chatProcessingStatus.includes('Enriching') || chatProcessingStatus.includes('Searching') || chatProcessingStatus.includes('Responding') ? 'bg-[var(--text-accent)]' : 'bg-[var(--border-secondary)]'"></div>
<div class="h-1 w-4 rounded" :class="chatProcessingStatus.includes('Searching') || chatProcessingStatus.includes('Responding') ? 'bg-[var(--text-accent)]' : 'bg-[var(--border-secondary)]'"></div>
<div class="h-1 w-4 rounded" :class="chatProcessingStatus.includes('Contextualizing') || chatProcessingStatus.includes('Responding') ? 'bg-[var(--text-accent)]' : 'bg-[var(--border-secondary)]'"></div>
<div class="h-1 w-4 rounded" :class="chatProcessingStatus.includes('Responding') ? 'bg-[var(--text-accent)] animate-pulse' : 'bg-[var(--border-secondary)]'"></div>
</div>
</div>
</div>
</div>
<!-- Chat Input Area -->
<div class="flex-shrink-0 border-t border-[var(--border-primary)] p-6 bg-[var(--bg-secondary)]">
<div class="flex gap-4">
<textarea v-model="inquireChatInput"
@keydown="handleInquireChatKeydown"
:placeholder="t('inquire.placeholder')"
class="flex-1 px-4 py-3 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] focus:border-transparent text-[var(--text-primary)]"
rows="3"></textarea>
<button @click="sendInquireChatMessage"
:disabled="!inquireChatInput.trim() || isInquireChatLoading"
class="px-6 py-3 bg-[var(--bg-button)] text-[var(--text-button)] rounded-xl hover:bg-[var(--bg-button-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[60px]">
<i class="fas fa-paper-plane text-lg"></i>
</button>
</div>
<div class="text-xs text-[var(--text-muted)] mt-3 flex items-center justify-between">
<span>${t('inquire.sendHint')}</span>
<span v-if="hasActiveFilters" class="text-[var(--text-accent)]">
<i class="fas fa-filter mr-1"></i>${t('inquire.filtersActive')}
</span>
</div>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toastContainer" class="fixed top-4 right-4 z-50 space-y-2"></div>
<!-- Global Error Banner -->
<div v-if="globalError" class="fixed top-16 left-1/2 transform -translate-x-1/2 z-50 max-w-lg w-full mx-4">
<div class="bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-3"></i>
<span class="text-sm font-medium">${ globalError }</span>
</div>
<button @click="globalError = null" class="ml-4 text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- Color Scheme Modal -->
<div v-if="showColorSchemeModal" class="color-scheme-modal" @click.self="closeColorSchemeModal">
<div class="color-scheme-modal-content">
<div class="color-scheme-header">
<h2 class="color-scheme-title">
<i class="fas fa-palette"></i>
<span v-text="t('colorScheme.title')"></span>
</h2>
<p class="color-scheme-subtitle" v-text="t('colorScheme.subtitle')"></p>
</div>
<div class="color-scheme-body">
<div class="color-scheme-section">
<h3 class="color-scheme-section-title">
<i :class="isDarkMode ? 'fas fa-moon' : 'fas fa-sun'"></i>
<span v-text="isDarkMode ? t('colorScheme.darkThemes') : t('colorScheme.lightThemes')"></span>
</h3>
<div class="color-scheme-grid">
<div v-for="scheme in colorSchemes[isDarkMode ? 'dark' : 'light']"
:key="scheme.id"
@click="selectColorScheme(scheme.id)"
:class="['color-scheme-option', currentColorScheme === scheme.id ? 'active' : '']">
<div class="color-scheme-preview">
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-primary color-scheme-preview-segment`"></div>
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-secondary color-scheme-preview-segment`"></div>
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-tertiary color-scheme-preview-segment`"></div>
</div>
<div class="color-scheme-name" v-text="t('colorScheme.themes.' + (isDarkMode ? 'dark' : 'light') + '.' + scheme.id + '.name')"></div>
<div class="color-scheme-description" v-text="t('colorScheme.themes.' + (isDarkMode ? 'dark' : 'light') + '.' + scheme.id + '.description')"></div>
<div v-if="currentColorScheme === scheme.id" class="color-scheme-check">
<i class="fas fa-check"></i>
</div>
</div>
</div>
</div>
</div>
<div class="color-scheme-footer">
<button @click="resetColorScheme" class="color-scheme-reset-btn">
<i class="fas fa-undo mr-2"></i><span v-text="t('colorScheme.resetToDefault')"></span>
</button>
<button @click="closeColorSchemeModal" class="color-scheme-close-btn" v-text="t('common.close')">
</button>
</div>
</div>
</div>
<!-- Shares List Modal -->
<div v-if="showSharesListModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div class="p-6 border-b border-[var(--border-primary)] flex justify-between items-center">
<h3 class="text-lg font-semibold" v-text="t('modal.sharedTranscripts')"></h3>
<button @click="closeSharesList" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6 space-y-4 overflow-y-auto">
<div v-if="isLoadingShares" class="text-center">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
</div>
<div v-else-if="userShares.length === 0" class="text-center text-[var(--text-muted)]">
<span v-text="t('sharedTranscripts.noSharedTranscripts')"></span>
</div>
<div v-else class="space-y-3">
<div v-for="share in userShares" :key="share.id" class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<div class="flex justify-between items-start">
<div>
<p class="font-semibold">${share.recording_title}</p>
<p class="text-sm text-[var(--text-muted)]">${ t('help.sharedOn') }: ${share.created_at}</p>
</div>
<button @click="deleteShare(share.id)" class="text-red-500 hover:text-red-700 p-1"><i class="fas fa-trash"></i></button>
</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">
<button @click="copyShareLink(share.public_id)" class="mt-2 px-3 py-1 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg text-sm hover:bg-[var(--bg-button-hover)]">
<i class="fas fa-copy mr-2"></i>Copy Link
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CSRF Token Management -->
<script src="{{ url_for('static', filename='js/csrf-refresh.js') }}"></script>
<!-- Shared Components -->
<script src="{{ url_for('static', filename='js/shared-components.js') }}"></script>
<!-- Inquire Mode Vue App -->
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted, nextTick } = Vue;
// Initialize i18n
let t = (key) => key; // Default fallback
async function initializeI18n() {
const userLanguage = "{{ 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);
}
}
document.addEventListener('DOMContentLoaded', async () => {
await initializeI18n();
const csrfToken = ref(document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'));
const i18nInstance = window.i18n;
createApp({
setup() {
// --- State ---
const inquireFilters = reactive({
selectedTags: [],
selectedSpeakers: [],
selectedRecordings: [],
dateFrom: null,
dateTo: null
});
// Search queries for filters
const tagSearchQuery = ref('');
const speakerSearchQuery = ref('');
// Use shared components
const { isDarkMode, toggleDarkMode, initializeDarkMode } = window.SharedComponents.useDarkMode();
const {
showColorSchemeModal,
currentColorScheme,
colorSchemes,
openColorSchemeModal,
closeColorSchemeModal,
selectColorScheme,
resetColorScheme,
applyColorScheme,
initializeColorScheme
} = window.SharedComponents.useColorScheme();
const {
showSharesListModal,
userShares,
isLoadingShares,
openSharesList,
closeSharesList,
copyShareLink,
deleteShare
} = window.SharedComponents.useSharesModal();
const { isUserMenuOpen, toggleUserMenu, closeUserMenu } = window.SharedComponents.useUserMenu();
const isMobileSidebarOpen = ref(false);
const availableFilters = reactive({
tags: [],
speakers: [],
recordings: []
});
const inquireChatMessages = ref([]);
const inquireChatInput = ref('');
const isInquireChatLoading = ref(false);
const chatProcessingStatus = ref('');
const showProcessingStatus = ref(false);
const inquireMessagesRef = ref(null);
const globalError = ref(null);
const shouldAutoScroll = ref(true); // Track if we should auto-scroll
const tokenBudget = ref({
has_budget: false,
budget: null,
usage: 0,
percentage: 0
});
// --- Computed ---
const hasActiveFilters = computed(() => {
return inquireFilters.selectedTags.length > 0 ||
inquireFilters.selectedSpeakers.length > 0 ||
inquireFilters.selectedRecordings.length > 0 ||
inquireFilters.dateFrom ||
inquireFilters.dateTo;
});
const filteredTags = computed(() => {
if (!tagSearchQuery.value) return availableFilters.tags;
return availableFilters.tags.filter(tag =>
tag.name.toLowerCase().includes(tagSearchQuery.value.toLowerCase())
);
});
const filteredSpeakers = computed(() => {
if (!speakerSearchQuery.value) return availableFilters.speakers;
return availableFilters.speakers.filter(speaker =>
speaker.toLowerCase().includes(speakerSearchQuery.value.toLowerCase())
);
});
// --- Functions ---
const setGlobalError = (message, duration = 7000) => {
globalError.value = message;
if (duration > 0) {
setTimeout(() => { if (globalError.value === message) globalError.value = null; }, duration);
}
};
const loadTokenBudget = async () => {
try {
const response = await fetch('/api/user/token-budget');
if (response.ok) {
tokenBudget.value = await response.json();
}
} catch (error) {
console.error('Error loading token budget:', error);
}
};
const loadAvailableFilters = async () => {
try {
const response = await fetch('/api/inquire/available_filters');
if (response.ok) {
const data = await response.json();
availableFilters.tags = data.tags;
availableFilters.speakers = data.speakers;
availableFilters.recordings = data.recordings;
}
} catch (error) {
console.error('Error loading available filters:', error);
}
};
const sendInquireChatMessage = async () => {
if (!inquireChatInput.value.trim() || isInquireChatLoading.value) return;
const userMessage = inquireChatInput.value.trim();
inquireChatMessages.value.push({ role: 'user', content: userMessage, html: userMessage });
inquireChatInput.value = '';
isInquireChatLoading.value = true;
showProcessingStatus.value = true;
chatProcessingStatus.value = 'Initializing...';
// Always scroll to bottom when user sends a message
shouldAutoScroll.value = true;
await nextTick();
scrollToBottom();
try {
const response = await fetch('/api/inquire/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken.value
},
body: JSON.stringify({
message: userMessage,
message_history: inquireChatMessages.value.slice(0, -1),
filter_tags: inquireFilters.selectedTags,
filter_speakers: inquireFilters.selectedSpeakers,
filter_recording_ids: inquireFilters.selectedRecordings,
filter_date_from: inquireFilters.dateFrom,
filter_date_to: inquireFilters.dateTo,
context_chunks: 8
})
});
if (!response.ok) {
throw new Error('Chat request failed');
}
// Handle streaming response
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantMessage = reactive({ role: 'assistant', content: '', html: '' });
let messageAdded = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.status && data.message) {
// Update processing status
chatProcessingStatus.value = data.message;
} else if (data.delta) {
// Hide status and start showing response
if (!messageAdded) {
inquireChatMessages.value.push(assistantMessage);
messageAdded = true;
showProcessingStatus.value = false;
}
assistantMessage.content += data.delta;
assistantMessage.html = marked.parse(assistantMessage.content);
// Smart scroll during streaming
await nextTick();
scrollToBottom();
} else if (data.end_of_stream) {
return;
} else if (data.error) {
if (data.budget_exceeded) {
throw new Error(t('adminDashboard.tokenBudgetExceeded'));
}
throw new Error(data.error);
}
} catch (parseError) {
console.warn('Failed to parse SSE data:', line);
}
}
}
}
} catch (error) {
console.error('Error in inquire chat:', error);
setGlobalError(error.message || 'Chat failed. Please try again.', 10000);
} finally {
isInquireChatLoading.value = false;
showProcessingStatus.value = false;
loadTokenBudget(); // Refresh token usage after chat
await nextTick();
scrollToBottom(); // Use smart scrolling
}
};
const handleInquireChatKeydown = (event) => {
if (event.key === 'Enter') {
if (event.ctrlKey || event.shiftKey) {
return;
} else {
event.preventDefault();
sendInquireChatMessage();
}
}
};
const clearInquireFilters = () => {
inquireFilters.selectedTags = [];
inquireFilters.selectedSpeakers = [];
inquireFilters.selectedRecordings = [];
inquireFilters.dateFrom = null;
inquireFilters.dateTo = null;
inquireChatMessages.value = [];
};
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString();
};
// Smart scrolling functions
const isNearBottom = () => {
if (!inquireMessagesRef.value) return true;
const container = inquireMessagesRef.value;
const threshold = 100; // pixels from bottom
return container.scrollTop + container.clientHeight >= container.scrollHeight - threshold;
};
const scrollToBottom = () => {
if (inquireMessagesRef.value && shouldAutoScroll.value) {
inquireMessagesRef.value.scrollTop = inquireMessagesRef.value.scrollHeight;
}
};
const handleScroll = () => {
if (inquireMessagesRef.value) {
shouldAutoScroll.value = isNearBottom();
}
};
// --- Theme Management (from main app.js) ---
const applyThemeFromStorage = () => {
const savedDarkMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = savedDarkMode !== null ? savedDarkMode === 'true' : prefersDark;
document.documentElement.classList.toggle('dark', isDark);
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const currentMode = isDark ? 'dark' : 'light';
const themeClass = savedScheme === 'blue' ? '' : `theme-${currentMode}-${savedScheme}`;
// Remove all existing theme classes
document.documentElement.className = document.documentElement.className
.replace(/theme-(?:light|dark)-\w+/g, '');
if (themeClass) {
document.documentElement.classList.add(themeClass);
}
};
/**
* Set up global click handler to close dropdowns when clicking outside
* Provides elegant UX by closing menus when users click elsewhere
*/
const setupGlobalClickHandler = () => {
document.addEventListener('click', (event) => {
const target = event.target;
// Close user menu if clicking outside of it
if (isUserMenuOpen.value) {
const userMenuButton = target.closest('[data-user-menu-toggle]');
const userMenuDropdown = target.closest('[data-user-menu-dropdown]');
if (!userMenuButton && !userMenuDropdown) {
isUserMenuOpen.value = false;
}
}
});
};
// --- Lifecycle ---
onMounted(async () => {
// Initialize dark mode and color scheme
initializeDarkMode();
initializeColorScheme(isDarkMode.value);
setupGlobalClickHandler();
// Watch dark mode changes to update color scheme
Vue.watch(isDarkMode, (newValue) => {
initializeColorScheme(newValue);
});
// Apply theme from localStorage on page load
applyThemeFromStorage();
loadAvailableFilters();
loadTokenBudget();
// Add scroll listener for smart auto-scroll after DOM is ready
await nextTick();
if (inquireMessagesRef.value) {
inquireMessagesRef.value.addEventListener('scroll', handleScroll);
}
});
onUnmounted(() => {
if (inquireMessagesRef.value) {
inquireMessagesRef.value.removeEventListener('scroll', handleScroll);
}
});
return {
// i18n - bind to i18n instance to maintain context
t: (key, params) => i18nInstance ? i18nInstance.t(key, params) : key,
// State
inquireFilters,
availableFilters,
inquireChatMessages,
inquireChatInput,
isInquireChatLoading,
inquireMessagesRef,
globalError,
tokenBudget,
isUserMenuOpen,
isMobileSidebarOpen,
chatProcessingStatus,
showProcessingStatus,
tagSearchQuery,
speakerSearchQuery,
// Dark Mode
isDarkMode,
toggleDarkMode,
// Color Scheme
showColorSchemeModal,
currentColorScheme,
colorSchemes,
openColorSchemeModal,
closeColorSchemeModal,
selectColorScheme,
resetColorScheme,
// Shares Modal
showSharesListModal,
userShares,
isLoadingShares,
openSharesList,
closeSharesList,
copyShareLink,
deleteShare,
// Computed
hasActiveFilters,
filteredTags,
filteredSpeakers,
// Functions
sendInquireChatMessage,
handleInquireChatKeydown,
clearInquireFilters,
loadAvailableFilters,
setGlobalError,
formatDate,
handleScroll
};
},
delimiters: ['${', '}']
}).mount('#inquire-app');
// Hide loading overlay after app mounts
Vue.nextTick(() => {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
});
</script>
</body>
</html>