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:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user