feat: Meeting Assistant MVP - Complete implementation
Enterprise Meeting Knowledge Management System with: Backend (FastAPI): - Authentication proxy with JWT (pj-auth-api integration) - MySQL database with 4 tables (users, meetings, conclusions, actions) - Meeting CRUD with system code generation (C-YYYYMMDD-XX, A-YYYYMMDD-XX) - Dify LLM integration for AI summarization - Excel export with openpyxl - 20 unit tests (all passing) Client (Electron): - Login page with company auth - Meeting list with create/delete - Meeting detail with real-time transcription - Editable transcript textarea (single block, easy editing) - AI summarization with conclusions/action items - 5-second segment recording (efficient for long meetings) Sidecar (Python): - faster-whisper medium model with int8 quantization - ONNX Runtime VAD (lightweight, ~20MB vs PyTorch ~2GB) - Chinese punctuation processing - OpenCC for Traditional Chinese conversion - Anti-hallucination parameters - Auto-cleanup of temp audio files OpenSpec: - add-meeting-assistant-mvp (47 tasks, archived) - add-realtime-transcription (29 tasks, archived) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
58
client/src/pages/login.html
Normal file
58
client/src/pages/login.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meeting Assistant - Login</title>
|
||||
<link rel="stylesheet" href="../styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h1>Meeting Assistant</h1>
|
||||
<div id="error-alert" class="alert alert-error hidden"></div>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required placeholder="your.email@company.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required placeholder="Enter your password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full" id="login-btn">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { login } from '../services/api.js';
|
||||
|
||||
const form = document.getElementById('login-form');
|
||||
const errorAlert = document.getElementById('error-alert');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Logging in...';
|
||||
errorAlert.classList.add('hidden');
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
window.electronAPI.navigate('meetings');
|
||||
} catch (error) {
|
||||
errorAlert.textContent = error.message;
|
||||
errorAlert.classList.remove('hidden');
|
||||
} finally {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Login';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
685
client/src/pages/meeting-detail.html
Normal file
685
client/src/pages/meeting-detail.html
Normal file
@@ -0,0 +1,685 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meeting Assistant - Meeting Detail</title>
|
||||
<link rel="stylesheet" href="../styles/main.css">
|
||||
<style>
|
||||
.transcript-segments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.transcript-segment {
|
||||
position: relative;
|
||||
padding: 10px 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.transcript-segment:hover {
|
||||
background: #fff;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
.transcript-segment.active {
|
||||
background: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
|
||||
}
|
||||
.transcript-segment.edited {
|
||||
border-left: 3px solid #ff9800;
|
||||
}
|
||||
.transcript-segment textarea {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
resize: vertical;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
}
|
||||
.transcript-segment textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
.segment-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
}
|
||||
.segment-id {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.segment-edited {
|
||||
color: #ff9800;
|
||||
font-weight: 500;
|
||||
}
|
||||
.streaming-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.streaming-status.hidden {
|
||||
display: none;
|
||||
}
|
||||
.pulse-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #f44336;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.2); }
|
||||
}
|
||||
.segment-count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.processing-indicator {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
.transcript-textarea {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
resize: vertical;
|
||||
font-family: 'Microsoft JhengHei', 'PingFang TC', sans-serif;
|
||||
}
|
||||
.transcript-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #2196F3;
|
||||
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1 id="meeting-title">Meeting Details</h1>
|
||||
<nav class="header-nav">
|
||||
<a href="#" id="back-btn">Back to List</a>
|
||||
<a href="#" id="export-btn">Export Excel</a>
|
||||
<a href="#" id="delete-btn" style="color: #ff6b6b;">Delete</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container" style="padding: 10px;">
|
||||
<!-- Meeting Info Bar -->
|
||||
<div class="card" style="margin-bottom: 10px;">
|
||||
<div class="card-body" style="padding: 10px 20px;">
|
||||
<div id="meeting-info" style="display: flex; flex-wrap: wrap; gap: 20px;">
|
||||
<span><strong>Time:</strong> <span id="info-time"></span></span>
|
||||
<span><strong>Location:</strong> <span id="info-location"></span></span>
|
||||
<span><strong>Chair:</strong> <span id="info-chair"></span></span>
|
||||
<span><strong>Recorder:</strong> <span id="info-recorder"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual Panel Layout -->
|
||||
<div class="dual-panel">
|
||||
<!-- Left Panel: Transcript -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span>Transcript (逐字稿)</span>
|
||||
<div class="recording-controls" style="padding: 0;">
|
||||
<button class="btn btn-danger" id="record-btn">Start Recording</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- Streaming Status -->
|
||||
<div id="streaming-status" class="streaming-status hidden">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>Recording & Transcribing...</span>
|
||||
<span class="segment-count" id="segment-count">Segments: 0</span>
|
||||
</div>
|
||||
|
||||
<!-- Single Transcript Textarea -->
|
||||
<div id="transcript-container">
|
||||
<textarea
|
||||
id="transcript-text"
|
||||
class="transcript-textarea"
|
||||
placeholder="Click 'Start Recording' to begin transcription. You can also type or paste text directly here."
|
||||
></textarea>
|
||||
<div class="processing-indicator hidden" id="processing-indicator">
|
||||
Processing audio...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Notes & Actions -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span>Notes & Actions</span>
|
||||
<button class="btn btn-primary" id="summarize-btn">AI Summarize</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- Conclusions -->
|
||||
<div class="mb-20">
|
||||
<h3 class="mb-10">Conclusions (結論)</h3>
|
||||
<div id="conclusions-list"></div>
|
||||
<button class="btn btn-secondary mt-10" id="add-conclusion-btn">+ Add Conclusion</button>
|
||||
</div>
|
||||
|
||||
<!-- Action Items -->
|
||||
<div>
|
||||
<h3 class="mb-10">Action Items (待辦事項)</h3>
|
||||
<div id="actions-list"></div>
|
||||
<button class="btn btn-secondary mt-10" id="add-action-btn">+ Add Action Item</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 15px 20px; border-top: 1px solid #dee2e6;">
|
||||
<button class="btn btn-success btn-full" id="save-btn">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
getMeeting,
|
||||
updateMeeting,
|
||||
deleteMeeting,
|
||||
exportMeeting,
|
||||
summarizeTranscript
|
||||
} from '../services/api.js';
|
||||
|
||||
const meetingId = localStorage.getItem('currentMeetingId');
|
||||
let currentMeeting = null;
|
||||
let isRecording = false;
|
||||
let audioContext = null;
|
||||
let mediaStream = null;
|
||||
let audioWorklet = null;
|
||||
let transcriptionCount = 0; // Track number of transcription chunks
|
||||
|
||||
// Elements
|
||||
const titleEl = document.getElementById('meeting-title');
|
||||
const timeEl = document.getElementById('info-time');
|
||||
const locationEl = document.getElementById('info-location');
|
||||
const chairEl = document.getElementById('info-chair');
|
||||
const recorderEl = document.getElementById('info-recorder');
|
||||
const transcriptTextEl = document.getElementById('transcript-text');
|
||||
const streamingStatusEl = document.getElementById('streaming-status');
|
||||
const segmentCountEl = document.getElementById('segment-count');
|
||||
const processingIndicatorEl = document.getElementById('processing-indicator');
|
||||
const conclusionsEl = document.getElementById('conclusions-list');
|
||||
const actionsEl = document.getElementById('actions-list');
|
||||
const recordBtn = document.getElementById('record-btn');
|
||||
const summarizeBtn = document.getElementById('summarize-btn');
|
||||
const saveBtn = document.getElementById('save-btn');
|
||||
const backBtn = document.getElementById('back-btn');
|
||||
const exportBtn = document.getElementById('export-btn');
|
||||
const deleteBtn = document.getElementById('delete-btn');
|
||||
const addConclusionBtn = document.getElementById('add-conclusion-btn');
|
||||
const addActionBtn = document.getElementById('add-action-btn');
|
||||
|
||||
// Load meeting data
|
||||
async function loadMeeting() {
|
||||
try {
|
||||
currentMeeting = await getMeeting(meetingId);
|
||||
renderMeeting();
|
||||
} catch (error) {
|
||||
alert('Error loading meeting: ' + error.message);
|
||||
window.electronAPI.navigate('meetings');
|
||||
}
|
||||
}
|
||||
|
||||
function renderMeeting() {
|
||||
titleEl.textContent = currentMeeting.subject;
|
||||
timeEl.textContent = new Date(currentMeeting.meeting_time).toLocaleString('zh-TW');
|
||||
locationEl.textContent = currentMeeting.location || '-';
|
||||
chairEl.textContent = currentMeeting.chairperson || '-';
|
||||
recorderEl.textContent = currentMeeting.recorder || '-';
|
||||
|
||||
// Load existing transcript
|
||||
if (currentMeeting.transcript_blob) {
|
||||
transcriptTextEl.value = currentMeeting.transcript_blob;
|
||||
}
|
||||
renderConclusions();
|
||||
renderActions();
|
||||
}
|
||||
|
||||
// Append new transcription text
|
||||
function appendTranscription(text) {
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
transcriptionCount++;
|
||||
const currentText = transcriptTextEl.value;
|
||||
|
||||
if (currentText.trim()) {
|
||||
// Append with proper separator
|
||||
transcriptTextEl.value = currentText.trimEnd() + '\n' + text.trim();
|
||||
} else {
|
||||
transcriptTextEl.value = text.trim();
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
transcriptTextEl.scrollTop = transcriptTextEl.scrollHeight;
|
||||
segmentCountEl.textContent = `Chunks: ${transcriptionCount}`;
|
||||
}
|
||||
|
||||
function getTranscript() {
|
||||
return transcriptTextEl.value;
|
||||
}
|
||||
|
||||
// === Recording with Segmented Transcription ===
|
||||
// Each segment is a fresh 5-second recording (fixed size, efficient for long meetings)
|
||||
let mediaRecorder = null;
|
||||
let currentChunks = [];
|
||||
let recordingCycleTimer = null;
|
||||
let isProcessing = false;
|
||||
const SEGMENT_DURATION = 5000; // 5 seconds per segment
|
||||
|
||||
recordBtn.addEventListener('click', async () => {
|
||||
if (!isRecording) {
|
||||
await startRecording();
|
||||
} else {
|
||||
await stopRecording();
|
||||
}
|
||||
});
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const status = await window.electronAPI.getSidecarStatus();
|
||||
if (!status.ready) {
|
||||
alert('Transcription engine is not ready. Please wait a moment and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true }
|
||||
});
|
||||
|
||||
isRecording = true;
|
||||
recordBtn.textContent = 'Stop Recording';
|
||||
recordBtn.classList.remove('btn-danger');
|
||||
recordBtn.classList.add('btn-secondary');
|
||||
streamingStatusEl.classList.remove('hidden');
|
||||
|
||||
// Start first recording cycle
|
||||
startRecordingCycle();
|
||||
|
||||
console.log('Recording started with segmented approach');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Start recording error:', error);
|
||||
alert('Error starting recording: ' + error.message);
|
||||
await cleanupRecording();
|
||||
}
|
||||
}
|
||||
|
||||
function startRecordingCycle() {
|
||||
if (!isRecording || !mediaStream) return;
|
||||
|
||||
currentChunks = [];
|
||||
|
||||
mediaRecorder = new MediaRecorder(mediaStream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
});
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
currentChunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
if (currentChunks.length > 0 && !isProcessing) {
|
||||
isProcessing = true;
|
||||
await transcribeCurrentSegment();
|
||||
isProcessing = false;
|
||||
}
|
||||
// Start next cycle if still recording
|
||||
if (isRecording && mediaStream) {
|
||||
startRecordingCycle();
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start(100); // Collect frequently for smooth stopping
|
||||
|
||||
// Schedule stop after SEGMENT_DURATION
|
||||
recordingCycleTimer = setTimeout(() => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}, SEGMENT_DURATION);
|
||||
}
|
||||
|
||||
async function transcribeCurrentSegment() {
|
||||
if (currentChunks.length === 0) return;
|
||||
|
||||
try {
|
||||
processingIndicatorEl.classList.remove('hidden');
|
||||
|
||||
const blob = new Blob(currentChunks, { type: 'audio/webm' });
|
||||
// Skip very small blobs (likely silence)
|
||||
if (blob.size < 1000) {
|
||||
processingIndicatorEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const filePath = await window.electronAPI.saveAudioFile(arrayBuffer);
|
||||
|
||||
const result = await window.electronAPI.transcribeAudio(filePath);
|
||||
if (result.result) {
|
||||
const text = result.result.trim();
|
||||
if (text) {
|
||||
appendTranscription(text);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Segment transcription error:', err);
|
||||
} finally {
|
||||
processingIndicatorEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
try {
|
||||
recordBtn.disabled = true;
|
||||
recordBtn.textContent = 'Processing...';
|
||||
|
||||
isRecording = false;
|
||||
|
||||
// Clear the cycle timer
|
||||
if (recordingCycleTimer) {
|
||||
clearTimeout(recordingCycleTimer);
|
||||
recordingCycleTimer = null;
|
||||
}
|
||||
|
||||
// Stop current recorder and process final segment
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
await new Promise(resolve => {
|
||||
const originalOnStop = mediaRecorder.onstop;
|
||||
mediaRecorder.onstop = async () => {
|
||||
if (currentChunks.length > 0) {
|
||||
await transcribeCurrentSegment();
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Stop recording error:', error);
|
||||
} finally {
|
||||
await cleanupRecording();
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupRecording() {
|
||||
isRecording = false;
|
||||
|
||||
if (recordingCycleTimer) {
|
||||
clearTimeout(recordingCycleTimer);
|
||||
recordingCycleTimer = null;
|
||||
}
|
||||
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
mediaRecorder = null;
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
|
||||
currentChunks = [];
|
||||
isProcessing = false;
|
||||
|
||||
recordBtn.disabled = false;
|
||||
recordBtn.textContent = 'Start Recording';
|
||||
recordBtn.classList.remove('btn-secondary');
|
||||
recordBtn.classList.add('btn-danger');
|
||||
streamingStatusEl.classList.add('hidden');
|
||||
processingIndicatorEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
// === Streaming Event Handlers (legacy, kept for future use) ===
|
||||
window.electronAPI.onTranscriptionSegment((segment) => {
|
||||
console.log('Received segment:', segment);
|
||||
processingIndicatorEl.classList.add('hidden');
|
||||
if (segment.text) {
|
||||
appendTranscription(segment.text);
|
||||
}
|
||||
if (isRecording) {
|
||||
processingIndicatorEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
window.electronAPI.onStreamStopped((data) => {
|
||||
console.log('Stream stopped event:', data);
|
||||
if (data.final_segments) {
|
||||
data.final_segments.forEach(seg => {
|
||||
if (seg.text) appendTranscription(seg.text);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// === Conclusions Rendering ===
|
||||
function renderConclusions() {
|
||||
if (!currentMeeting.conclusions || currentMeeting.conclusions.length === 0) {
|
||||
conclusionsEl.innerHTML = '<p style="color: #666;">No conclusions yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
conclusionsEl.innerHTML = currentMeeting.conclusions.map((c, i) => `
|
||||
<div class="action-item">
|
||||
<div class="action-item-header">
|
||||
<span class="action-item-code">${c.system_code || 'NEW'}</span>
|
||||
<button class="btn btn-danger" style="padding: 4px 8px; font-size: 0.8rem;" onclick="removeConclusion(${i})">Remove</button>
|
||||
</div>
|
||||
<textarea
|
||||
class="conclusion-content"
|
||||
data-index="${i}"
|
||||
style="width: 100%; min-height: 60px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"
|
||||
>${c.content}</textarea>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderActions() {
|
||||
if (!currentMeeting.actions || currentMeeting.actions.length === 0) {
|
||||
actionsEl.innerHTML = '<p style="color: #666;">No action items yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
actionsEl.innerHTML = currentMeeting.actions.map((a, i) => `
|
||||
<div class="action-item">
|
||||
<div class="action-item-header">
|
||||
<span class="action-item-code">${a.system_code || 'NEW'}</span>
|
||||
<select class="action-status" data-index="${i}" style="padding: 4px 8px;">
|
||||
<option value="Open" ${a.status === 'Open' ? 'selected' : ''}>Open</option>
|
||||
<option value="In Progress" ${a.status === 'In Progress' ? 'selected' : ''}>In Progress</option>
|
||||
<option value="Done" ${a.status === 'Done' ? 'selected' : ''}>Done</option>
|
||||
<option value="Delayed" ${a.status === 'Delayed' ? 'selected' : ''}>Delayed</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea
|
||||
class="action-content"
|
||||
data-index="${i}"
|
||||
style="width: 100%; min-height: 40px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 8px;"
|
||||
>${a.content}</textarea>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: center;">
|
||||
<input type="text" class="action-owner" data-index="${i}" value="${a.owner || ''}" placeholder="Owner" style="padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<input type="date" class="action-due" data-index="${i}" value="${a.due_date || ''}" style="padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<button class="btn btn-danger" style="padding: 8px 12px;" onclick="removeAction(${i})">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
window.removeConclusion = function(index) {
|
||||
currentMeeting.conclusions.splice(index, 1);
|
||||
renderConclusions();
|
||||
};
|
||||
|
||||
window.removeAction = function(index) {
|
||||
currentMeeting.actions.splice(index, 1);
|
||||
renderActions();
|
||||
};
|
||||
|
||||
addConclusionBtn.addEventListener('click', () => {
|
||||
if (!currentMeeting.conclusions) currentMeeting.conclusions = [];
|
||||
currentMeeting.conclusions.push({ content: '' });
|
||||
renderConclusions();
|
||||
});
|
||||
|
||||
addActionBtn.addEventListener('click', () => {
|
||||
if (!currentMeeting.actions) currentMeeting.actions = [];
|
||||
currentMeeting.actions.push({ content: '', owner: '', due_date: null, status: 'Open' });
|
||||
renderActions();
|
||||
});
|
||||
|
||||
// === AI Summarize ===
|
||||
summarizeBtn.addEventListener('click', async () => {
|
||||
const transcript = getTranscript();
|
||||
if (!transcript || transcript.trim() === '') {
|
||||
alert('Please record or add transcript segments first.');
|
||||
return;
|
||||
}
|
||||
|
||||
summarizeBtn.disabled = true;
|
||||
summarizeBtn.textContent = 'Summarizing...';
|
||||
|
||||
try {
|
||||
const result = await summarizeTranscript(transcript);
|
||||
|
||||
if (result.conclusions && result.conclusions.length > 0) {
|
||||
if (!currentMeeting.conclusions) currentMeeting.conclusions = [];
|
||||
result.conclusions.forEach(c => {
|
||||
currentMeeting.conclusions.push({ content: c });
|
||||
});
|
||||
}
|
||||
|
||||
if (result.action_items && result.action_items.length > 0) {
|
||||
if (!currentMeeting.actions) currentMeeting.actions = [];
|
||||
result.action_items.forEach(a => {
|
||||
currentMeeting.actions.push({
|
||||
content: a.content,
|
||||
owner: a.owner || '',
|
||||
due_date: a.due_date || null,
|
||||
status: 'Open'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderConclusions();
|
||||
renderActions();
|
||||
} catch (error) {
|
||||
alert('Error summarizing: ' + error.message);
|
||||
} finally {
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI Summarize';
|
||||
}
|
||||
});
|
||||
|
||||
// === Save ===
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const conclusions = [];
|
||||
document.querySelectorAll('.conclusion-content').forEach((el) => {
|
||||
conclusions.push({ content: el.value });
|
||||
});
|
||||
|
||||
const actions = [];
|
||||
document.querySelectorAll('.action-content').forEach((el, i) => {
|
||||
const statusEl = document.querySelector(`.action-status[data-index="${i}"]`);
|
||||
const ownerEl = document.querySelector(`.action-owner[data-index="${i}"]`);
|
||||
const dueEl = document.querySelector(`.action-due[data-index="${i}"]`);
|
||||
actions.push({
|
||||
content: el.value,
|
||||
owner: ownerEl?.value || '',
|
||||
due_date: dueEl?.value || null,
|
||||
status: statusEl?.value || 'Open'
|
||||
});
|
||||
});
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
await updateMeeting(meetingId, {
|
||||
transcript_blob: getTranscript(),
|
||||
conclusions: conclusions,
|
||||
actions: actions
|
||||
});
|
||||
alert('Meeting saved successfully!');
|
||||
loadMeeting();
|
||||
} catch (error) {
|
||||
alert('Error saving: ' + error.message);
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Changes';
|
||||
}
|
||||
});
|
||||
|
||||
// === Export ===
|
||||
exportBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const blob = await exportMeeting(meetingId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `meeting_${currentMeeting.uuid}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
alert('Error exporting: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// === Delete ===
|
||||
deleteBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!confirm('Are you sure you want to delete this meeting? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await deleteMeeting(meetingId);
|
||||
alert('Meeting deleted.');
|
||||
window.electronAPI.navigate('meetings');
|
||||
} catch (error) {
|
||||
alert('Error deleting: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// === Back ===
|
||||
backBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.electronAPI.navigate('meetings');
|
||||
});
|
||||
|
||||
// === Initialize ===
|
||||
if (meetingId) {
|
||||
loadMeeting();
|
||||
} else {
|
||||
window.electronAPI.navigate('meetings');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
201
client/src/pages/meetings.html
Normal file
201
client/src/pages/meetings.html
Normal file
@@ -0,0 +1,201 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meeting Assistant - Meetings</title>
|
||||
<link rel="stylesheet" href="../styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>Meeting Assistant</h1>
|
||||
<nav class="header-nav">
|
||||
<a href="#" id="new-meeting-btn">New Meeting</a>
|
||||
<a href="#" id="logout-btn">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
My Meetings
|
||||
</div>
|
||||
<div class="card-body" id="meetings-container">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Meeting Modal -->
|
||||
<div id="new-meeting-modal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>New Meeting</h2>
|
||||
<button class="modal-close" id="close-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="new-meeting-form">
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject *</label>
|
||||
<input type="text" id="subject" name="subject" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meeting_time">Date & Time *</label>
|
||||
<input type="datetime-local" id="meeting_time" name="meeting_time" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="chairperson">Chairperson</label>
|
||||
<input type="text" id="chairperson" name="chairperson">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="location">Location</label>
|
||||
<input type="text" id="location" name="location">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="attendees">Attendees (comma separated)</label>
|
||||
<input type="text" id="attendees" name="attendees" placeholder="email1@company.com, email2@company.com">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="create-btn">Create Meeting</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { getMeetings, createMeeting, clearToken } from '../services/api.js';
|
||||
|
||||
const meetingsContainer = document.getElementById('meetings-container');
|
||||
const newMeetingBtn = document.getElementById('new-meeting-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const modal = document.getElementById('new-meeting-modal');
|
||||
const closeModalBtn = document.getElementById('close-modal');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
const createBtn = document.getElementById('create-btn');
|
||||
const form = document.getElementById('new-meeting-form');
|
||||
|
||||
async function loadMeetings() {
|
||||
try {
|
||||
const meetings = await getMeetings();
|
||||
if (meetings.length === 0) {
|
||||
meetingsContainer.innerHTML = '<p class="text-center">No meetings yet. Create your first meeting!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
meetingsContainer.innerHTML = `
|
||||
<ul class="meeting-list">
|
||||
${meetings.map(m => `
|
||||
<li class="meeting-item" data-id="${m.meeting_id}">
|
||||
<div class="meeting-info">
|
||||
<h3>${escapeHtml(m.subject)}</h3>
|
||||
<p>${new Date(m.meeting_time).toLocaleString('zh-TW')} | ${escapeHtml(m.chairperson || 'No chairperson')}</p>
|
||||
</div>
|
||||
<div class="meeting-actions">
|
||||
<button class="btn btn-primary btn-small view-btn">View</button>
|
||||
</div>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const id = btn.closest('.meeting-item').dataset.id;
|
||||
localStorage.setItem('currentMeetingId', id);
|
||||
window.electronAPI.navigate('meeting-detail');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.meeting-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
localStorage.setItem('currentMeetingId', item.dataset.id);
|
||||
window.electronAPI.navigate('meeting-detail');
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
meetingsContainer.innerHTML = `<div class="alert alert-error">${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
modal.classList.remove('hidden');
|
||||
// Set default datetime to now
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
document.getElementById('meeting_time').value = now.toISOString().slice(0, 16);
|
||||
// Set default recorder to current user
|
||||
document.getElementById('recorder') && (document.getElementById('recorder').value = localStorage.getItem('userEmail') || '');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.add('hidden');
|
||||
form.reset();
|
||||
}
|
||||
|
||||
newMeetingBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openModal();
|
||||
});
|
||||
|
||||
closeModalBtn.addEventListener('click', closeModal);
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
|
||||
createBtn.addEventListener('click', async () => {
|
||||
const formData = new FormData(form);
|
||||
const meeting = {
|
||||
subject: formData.get('subject'),
|
||||
meeting_time: formData.get('meeting_time'),
|
||||
chairperson: formData.get('chairperson'),
|
||||
location: formData.get('location'),
|
||||
attendees: formData.get('attendees'),
|
||||
recorder: localStorage.getItem('userEmail') || '',
|
||||
};
|
||||
|
||||
if (!meeting.subject || !meeting.meeting_time) {
|
||||
alert('Please fill in required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
createBtn.disabled = true;
|
||||
createBtn.textContent = 'Creating...';
|
||||
|
||||
try {
|
||||
const created = await createMeeting(meeting);
|
||||
closeModal();
|
||||
localStorage.setItem('currentMeetingId', created.meeting_id);
|
||||
window.electronAPI.navigate('meeting-detail');
|
||||
} catch (error) {
|
||||
alert('Error creating meeting: ' + error.message);
|
||||
} finally {
|
||||
createBtn.disabled = false;
|
||||
createBtn.textContent = 'Create Meeting';
|
||||
}
|
||||
});
|
||||
|
||||
logoutBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
clearToken();
|
||||
window.electronAPI.navigate('login');
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
// Load meetings on page load
|
||||
loadMeetings();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user