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:
4118
client/package-lock.json
generated
Normal file
4118
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
client/package.json
Normal file
47
client/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "meeting-assistant",
|
||||
"version": "1.0.0",
|
||||
"description": "Enterprise Meeting Knowledge Management",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build": "electron-builder",
|
||||
"pack": "electron-builder --dir"
|
||||
},
|
||||
"author": "Your Company",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.company.meeting-assistant",
|
||||
"productName": "Meeting Assistant",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../sidecar/dist",
|
||||
"to": "sidecar",
|
||||
"filter": ["**/*"]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": "portable"
|
||||
},
|
||||
"mac": {
|
||||
"target": "dmg"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage"
|
||||
}
|
||||
}
|
||||
}
|
||||
278
client/src/main.js
Normal file
278
client/src/main.js
Normal 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);
|
||||
});
|
||||
});
|
||||
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>
|
||||
32
client/src/preload.js
Normal file
32
client/src/preload.js
Normal 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
149
client/src/services/api.js
Normal 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
462
client/src/styles/main.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user