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:
egg
2025-12-10 20:17:44 +08:00
commit 8b6184ecc5
65 changed files with 10510 additions and 0 deletions

278
client/src/main.js Normal file
View File

@@ -0,0 +1,278 @@
const { app, BrowserWindow, ipcMain } = require("electron");
const path = require("path");
const fs = require("fs");
const { spawn } = require("child_process");
const os = require("os");
let mainWindow;
let sidecarProcess;
let sidecarReady = false;
let streamingActive = false;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "preload.js"),
},
});
mainWindow.loadFile(path.join(__dirname, "pages", "login.html"));
mainWindow.on("closed", () => {
mainWindow = null;
});
}
function startSidecar() {
const sidecarDir = app.isPackaged
? path.join(process.resourcesPath, "sidecar")
: path.join(__dirname, "..", "..", "sidecar");
const sidecarScript = path.join(sidecarDir, "transcriber.py");
const venvPython = path.join(sidecarDir, "venv", "bin", "python");
if (!fs.existsSync(sidecarScript)) {
console.log("Sidecar script not found at:", sidecarScript);
console.log("Transcription will not be available.");
return;
}
const pythonPath = fs.existsSync(venvPython) ? venvPython : "python3";
try {
console.log("Starting sidecar with:", pythonPath, sidecarScript);
sidecarProcess = spawn(pythonPath, [sidecarScript], {
cwd: sidecarDir,
stdio: ["pipe", "pipe", "pipe"],
});
// Handle stdout (JSON responses)
sidecarProcess.stdout.on("data", (data) => {
const lines = data.toString().split("\n").filter(l => l.trim());
for (const line of lines) {
try {
const msg = JSON.parse(line);
console.log("Sidecar response:", msg);
if (msg.status === "ready") {
sidecarReady = true;
console.log("Sidecar is ready");
}
// Forward streaming segment to renderer
if (msg.segment_id !== undefined && mainWindow) {
mainWindow.webContents.send("transcription-segment", msg);
}
// Forward stream status changes
if (msg.status === "streaming" && mainWindow) {
mainWindow.webContents.send("stream-started", msg);
}
if (msg.status === "stream_stopped" && mainWindow) {
mainWindow.webContents.send("stream-stopped", msg);
}
// Legacy: file-based transcription result
if (msg.result !== undefined && mainWindow) {
mainWindow.webContents.send("transcription-result", msg.result);
}
} catch (e) {
console.log("Sidecar output:", line);
}
}
});
sidecarProcess.stderr.on("data", (data) => {
console.log("Sidecar:", data.toString().trim());
});
sidecarProcess.on("close", (code) => {
console.log(`Sidecar exited with code ${code}`);
sidecarReady = false;
streamingActive = false;
});
sidecarProcess.on("error", (err) => {
console.error("Sidecar error:", err.message);
});
} catch (error) {
console.error("Failed to start sidecar:", error);
}
}
app.whenReady().then(() => {
createWindow();
startSidecar();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (sidecarProcess) {
try {
sidecarProcess.stdin.write(JSON.stringify({ action: "quit" }) + "\n");
} catch (e) {}
sidecarProcess.kill();
}
if (process.platform !== "darwin") {
app.quit();
}
});
// IPC handlers
ipcMain.handle("navigate", (event, page) => {
mainWindow.loadFile(path.join(__dirname, "pages", `${page}.html`));
});
ipcMain.handle("get-sidecar-status", () => {
return { ready: sidecarReady, streaming: streamingActive };
});
// === Streaming Mode IPC Handlers ===
ipcMain.handle("start-recording-stream", async () => {
if (!sidecarProcess || !sidecarReady) {
return { error: "Sidecar not ready" };
}
if (streamingActive) {
return { error: "Stream already active" };
}
return new Promise((resolve) => {
const responseHandler = (data) => {
const lines = data.toString().split("\n").filter(l => l.trim());
for (const line of lines) {
try {
const msg = JSON.parse(line);
if (msg.status === "streaming" || msg.error) {
sidecarProcess.stdout.removeListener("data", responseHandler);
if (msg.status === "streaming") {
streamingActive = true;
}
resolve(msg);
return;
}
} catch (e) {}
}
};
sidecarProcess.stdout.on("data", responseHandler);
sidecarProcess.stdin.write(JSON.stringify({ action: "start_stream" }) + "\n");
setTimeout(() => {
sidecarProcess.stdout.removeListener("data", responseHandler);
resolve({ error: "Start stream timeout" });
}, 5000);
});
});
ipcMain.handle("stream-audio-chunk", async (event, base64Audio) => {
if (!sidecarProcess || !sidecarReady || !streamingActive) {
return { error: "Stream not active" };
}
try {
const cmd = JSON.stringify({ action: "audio_chunk", data: base64Audio }) + "\n";
sidecarProcess.stdin.write(cmd);
return { sent: true };
} catch (e) {
return { error: e.message };
}
});
ipcMain.handle("stop-recording-stream", async () => {
if (!sidecarProcess || !streamingActive) {
return { error: "No active stream" };
}
return new Promise((resolve) => {
const responseHandler = (data) => {
const lines = data.toString().split("\n").filter(l => l.trim());
for (const line of lines) {
try {
const msg = JSON.parse(line);
if (msg.status === "stream_stopped" || msg.error) {
sidecarProcess.stdout.removeListener("data", responseHandler);
streamingActive = false;
resolve(msg);
return;
}
} catch (e) {}
}
};
sidecarProcess.stdout.on("data", responseHandler);
sidecarProcess.stdin.write(JSON.stringify({ action: "stop_stream" }) + "\n");
setTimeout(() => {
sidecarProcess.stdout.removeListener("data", responseHandler);
streamingActive = false;
resolve({ error: "Stop stream timeout" });
}, 10000);
});
});
// === Legacy File-based Handlers (kept for fallback) ===
ipcMain.handle("save-audio-file", async (event, arrayBuffer) => {
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, `recording_${Date.now()}.webm`);
const buffer = Buffer.from(arrayBuffer);
fs.writeFileSync(tempFile, buffer);
return tempFile;
});
ipcMain.handle("transcribe-audio", async (event, audioFilePath) => {
if (!sidecarProcess || !sidecarReady) {
return { error: "Sidecar not ready" };
}
return new Promise((resolve) => {
const responseHandler = (data) => {
const lines = data.toString().split("\n").filter(l => l.trim());
for (const line of lines) {
try {
const msg = JSON.parse(line);
if (msg.result !== undefined || msg.error) {
sidecarProcess.stdout.removeListener("data", responseHandler);
// Delete temp file after transcription
try {
if (fs.existsSync(audioFilePath)) {
fs.unlinkSync(audioFilePath);
}
} catch (e) {
console.error("Failed to delete temp file:", e);
}
resolve(msg);
return;
}
} catch (e) {}
}
};
sidecarProcess.stdout.on("data", responseHandler);
const cmd = JSON.stringify({ action: "transcribe", file: audioFilePath }) + "\n";
sidecarProcess.stdin.write(cmd);
setTimeout(() => {
sidecarProcess.stdout.removeListener("data", responseHandler);
// Delete temp file on timeout too
try {
if (fs.existsSync(audioFilePath)) {
fs.unlinkSync(audioFilePath);
}
} catch (e) {}
resolve({ error: "Transcription timeout" });
}, 60000);
});
});

View 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>

View 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>

View 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">&times;</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>

32
client/src/preload.js Normal file
View File

@@ -0,0 +1,32 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
// Navigation
navigate: (page) => ipcRenderer.invoke("navigate", page),
// Sidecar status
getSidecarStatus: () => ipcRenderer.invoke("get-sidecar-status"),
// === Streaming Mode APIs ===
startRecordingStream: () => ipcRenderer.invoke("start-recording-stream"),
streamAudioChunk: (base64Audio) => ipcRenderer.invoke("stream-audio-chunk", base64Audio),
stopRecordingStream: () => ipcRenderer.invoke("stop-recording-stream"),
// Streaming events
onTranscriptionSegment: (callback) => {
ipcRenderer.on("transcription-segment", (event, segment) => callback(segment));
},
onStreamStarted: (callback) => {
ipcRenderer.on("stream-started", (event, data) => callback(data));
},
onStreamStopped: (callback) => {
ipcRenderer.on("stream-stopped", (event, data) => callback(data));
},
// === Legacy File-based APIs (fallback) ===
saveAudioFile: (arrayBuffer) => ipcRenderer.invoke("save-audio-file", arrayBuffer),
transcribeAudio: (filePath) => ipcRenderer.invoke("transcribe-audio", filePath),
onTranscriptionResult: (callback) => {
ipcRenderer.on("transcription-result", (event, text) => callback(text));
},
});

149
client/src/services/api.js Normal file
View File

@@ -0,0 +1,149 @@
const API_BASE_URL = "http://localhost:8000/api";
let authToken = null;
let tokenRefreshTimer = null;
export function setToken(token) {
authToken = token;
localStorage.setItem("authToken", token);
scheduleTokenRefresh();
}
export function getToken() {
if (!authToken) {
authToken = localStorage.getItem("authToken");
}
return authToken;
}
export function clearToken() {
authToken = null;
localStorage.removeItem("authToken");
if (tokenRefreshTimer) {
clearTimeout(tokenRefreshTimer);
}
}
function scheduleTokenRefresh() {
// Refresh token 5 minutes before expiry (assuming 24h token)
const refreshIn = 23 * 60 * 60 * 1000; // 23 hours
if (tokenRefreshTimer) {
clearTimeout(tokenRefreshTimer);
}
tokenRefreshTimer = setTimeout(async () => {
try {
// Re-login would require stored credentials
// For now, just notify user to re-login
console.warn("Token expiring soon, please re-login");
} catch (error) {
console.error("Token refresh failed:", error);
}
}, refreshIn);
}
async function request(endpoint, options = {}) {
const url = `${API_BASE_URL}${endpoint}`;
const headers = {
"Content-Type": "application/json",
...options.headers,
};
const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
const error = await response.json();
if (error.detail?.error === "token_expired") {
clearToken();
window.electronAPI.navigate("login");
throw new Error("Session expired, please login again");
}
throw new Error(error.detail || "Unauthorized");
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP error ${response.status}`);
}
// Handle blob responses for export
if (options.responseType === "blob") {
return response.blob();
}
return response.json();
}
// Auth API
export async function login(email, password) {
const data = await request("/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
setToken(data.token);
localStorage.setItem("userEmail", data.email);
localStorage.setItem("userRole", data.role);
return data;
}
export async function getMe() {
return request("/me");
}
// Meetings API
export async function getMeetings() {
return request("/meetings");
}
export async function getMeeting(id) {
return request(`/meetings/${id}`);
}
export async function createMeeting(meeting) {
return request("/meetings", {
method: "POST",
body: JSON.stringify(meeting),
});
}
export async function updateMeeting(id, meeting) {
return request(`/meetings/${id}`, {
method: "PUT",
body: JSON.stringify(meeting),
});
}
export async function deleteMeeting(id) {
return request(`/meetings/${id}`, {
method: "DELETE",
});
}
export async function updateActionItem(meetingId, actionId, data) {
return request(`/meetings/${meetingId}/actions/${actionId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
// AI API
export async function summarizeTranscript(transcript) {
return request("/ai/summarize", {
method: "POST",
body: JSON.stringify({ transcript }),
});
}
// Export API
export async function exportMeeting(id) {
return request(`/meetings/${id}/export`, {
responseType: "blob",
});
}

462
client/src/styles/main.css Normal file
View File

@@ -0,0 +1,462 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* Header */
.header {
background-color: #2c3e50;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.5rem;
}
.header-nav {
display: flex;
gap: 15px;
}
.header-nav a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 4px;
transition: background-color 0.2s;
}
.header-nav a:hover {
background-color: #34495e;
}
/* Login Page */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
}
.login-box h1 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
/* Forms */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #555;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
/* Buttons */
.btn {
display: inline-block;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: all 0.2s;
}
.btn-primary {
background-color: #667eea;
color: white;
}
.btn-primary:hover {
background-color: #5a6fd6;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-success {
background-color: #28a745;
color: white;
}
.btn-success:hover {
background-color: #218838;
}
.btn-full {
width: 100%;
}
/* Cards */
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
overflow: hidden;
}
.card-header {
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
font-weight: 600;
}
.card-body {
padding: 20px;
}
/* Meeting List */
.meeting-list {
list-style: none;
}
.meeting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #eee;
transition: background-color 0.2s;
cursor: pointer;
}
.meeting-item:hover {
background-color: #f8f9fa;
}
.meeting-item:last-child {
border-bottom: none;
}
.meeting-info h3 {
margin-bottom: 5px;
color: #333;
}
.meeting-info p {
color: #666;
font-size: 0.9rem;
}
.meeting-actions {
display: flex;
gap: 10px;
}
/* Dual Panel Layout */
.dual-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
height: calc(100vh - 150px);
}
.panel {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-body {
flex: 1;
padding: 20px;
overflow-y: auto;
}
/* Transcript */
.transcript-content {
white-space: pre-wrap;
font-family: "Courier New", monospace;
font-size: 0.95rem;
line-height: 1.8;
}
/* Action Items */
.action-item {
padding: 15px;
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 15px;
}
.action-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.action-item-code {
font-weight: 600;
color: #667eea;
}
.action-item-status {
padding: 4px 10px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
}
.status-open {
background-color: #e3f2fd;
color: #1976d2;
}
.status-in-progress {
background-color: #fff3e0;
color: #f57c00;
}
.status-done {
background-color: #e8f5e9;
color: #388e3c;
}
.status-delayed {
background-color: #ffebee;
color: #d32f2f;
}
/* Recording */
.recording-controls {
display: flex;
gap: 15px;
align-items: center;
padding: 15px 20px;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
}
.recording-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.recording-dot {
width: 12px;
height: 12px;
background-color: #dc3545;
border-radius: 50%;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Alerts */
.alert {
padding: 15px 20px;
border-radius: 6px;
margin-bottom: 20px;
}
.alert-error {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ef9a9a;
}
.alert-success {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
/* Loading */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 20px;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Utility */
.text-center {
text-align: center;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.mb-10 {
margin-bottom: 10px;
}
.mb-20 {
margin-bottom: 20px;
}
.hidden {
display: none !important;
}