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);
});
});