Compare commits

...

35 Commits

Author SHA1 Message Date
egg
4a2efb3b9b debug: Add startup mode logging
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 18:57:21 +08:00
egg
9da6c91dbe fix: Add missing browser-api.js functions for browser mode
- Add getConfig() for app initialization
- Add openInBrowser() (no-op in browser mode)
- Add onTranscriptionResult() for compatibility
- Add onStreamStarted() for compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 18:56:40 +08:00
egg
e68c5ebd9f config: Enable browser-only launch mode by default
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 18:25:15 +08:00
egg
fd203ef771 feat: Add browser-only launch mode for Kaspersky bypass
- Add `ui.launchBrowser` config option to launch browser directly
- Fix sidecar_manager to support packaged mode paths
- Set BROWSER_MODE env var for backend sidecar management
- Skip Electron window when browser-only mode enabled

Usage: Set `"ui": { "launchBrowser": true }` in config.json
to bypass Kaspersky blocking by using system browser instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 18:15:45 +08:00
egg
771655e03e fix: Browser mode 404 error for meeting-detail page
- Preserve query parameters (e.g., ?id=123) when opening in browser
- Add packaged mode detection for CLIENT_DIR path resolution
- Include client files in extraResources for backend to serve
- Add debug logging for client directory detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 17:50:14 +08:00
egg
7d3fc72bd2 feat: Add browser mode fallback for Kaspersky audio blocking
- Add sidecar management to backend (sidecar_manager.py)
- Add sidecar API router for browser mode (/api/sidecar/*)
- Add browser-api.js polyfill for running in Chrome/Edge
- Add "Open in Browser" button when audio access fails
- Update build scripts with new sidecar modules
- Add start-browser.sh for development browser mode

Browser mode allows users to open the app in their system browser
when Electron's audio access is blocked by security software.
The backend manages the sidecar process in browser mode (BROWSER_MODE=true).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 16:41:25 +08:00
egg
e7a06e2b8f chore: Archive all pending OpenSpec proposals
Force archive the following proposals:
- add-audio-device-selector (complete)
- add-embedded-backend-packaging (19/26 tasks)
- add-flexible-deployment-options (20/21 tasks)

New specs created:
- audio-device-management (7 requirements)
- embedded-backend (8 requirements)

Updated specs:
- transcription (+2 requirements for model download progress)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 08:44:04 +08:00
egg
c36f4167f2 feat: Add audio device selector and test recording panel
Add a new collapsible panel in meeting-detail page for audio device
management with the following features:

- Device dropdown selector showing all available microphones
- Real-time volume meter using Web Audio API AnalyserNode
- Test recording function (5 seconds max with countdown)
- Test playback function to verify recording quality
- Device hot-plug detection
- Preference persistence in localStorage
- Traditional Chinese localization

The main recording function now uses the user-selected device instead
of auto-selecting, giving users full control over which microphone
to use for meeting transcription.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 08:40:44 +08:00
egg
6112799c79 fix: Improve microphone device selection to avoid alias deviceIds
- Add isAlias() helper to identify 'default' and 'communications' aliases
- Prefer actual device IDs (long strings) over alias IDs
- For alias deviceIds, use {audio: true} to let system choose
- For real deviceIds, try exact first, then ideal as fallback
- Add debug logging for isSecureContext and alias detection

This fixes the "Could not start audio source" error when Electron tries
to open a microphone using exact: 'communications' constraint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:50:49 +08:00
egg
9a6ca5730b debug: Add detailed sidecar startup logging
Add more console.log statements to help debug sidecar startup issues:
- Log when startSidecar() is called
- Log sidecar directory and packaged status
- Log executable path check results

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:53:07 +08:00
egg
c05fdad8e4 fix: Improve Whisper model status error handling
- Set sidecarReady to false when model_error is received
- Store error message in activeWhisperConfig for status display
- Update frontend to show error state with red indicator
- Prevents showing  when model failed to load

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:46:51 +08:00
egg
0defc829dd fix: Make SQLite default for embedded-backend mode
When --embedded-backend is specified, SQLite is now the default database
type instead of MySQL. This is the expected behavior for all-in-one
deployment where the app should work offline without external database.

Users can still explicitly specify --database-type mysql if needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 09:10:45 +08:00
egg
a9bd6b34f1 fix: Remove missing icon references and fix batch syntax
- Remove icon references from package.json (assets folder is empty)
- Fix batch script syntax by replacing if/else blocks with goto labels
- Remove parentheses and colons that conflict with batch syntax

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:37:08 +08:00
egg
56cf0c072c fix: Batch script syntax error in build-client.bat
- Replace nested if statements with goto for target validation
- Remove parentheses from echo statements that conflict with if blocks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:29:10 +08:00
egg
08cffbb74a feat: Add NSIS installer target and persistent SQLite storage
- Add NSIS installer as default target (--target nsis)
- Keep portable option available (--target portable)
- Store SQLite database in %APPDATA%\Meeting-Assistant for persistence
- Portable temp folder cleanup no longer affects SQLite data
- Update build-client.bat with --target parameter support
- Update DEPLOYMENT.md with new options and comparisons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:13:32 +08:00
egg
bc37a5392a chore: Add tzdata to backend requirements
Fixes PyInstaller warning about missing tzdata package on Windows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 20:56:51 +08:00
egg
6f09c5f7cc fix: Correct hidden imports in build-client.bat
- Remove non-existent modules: app.auth, app.routers.dify,
  app.routers.health, app.routers.excel
- Add correct modules: app.routers.ai, app.routers.export,
  app.models, app.models.schemas
- Add sqlite3 and tzdata hidden imports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 20:49:07 +08:00
egg
d75789f23e fix: Improve Whisper model status verification and PyInstaller builds
- Add robust model cache verification (check model.bin + config.json)
- Add new status messages: model_cached, incomplete_cache, model_error
- Forward model status events to frontend for better UI feedback
- Add clean_build_cache() to remove stale spec files before build
- Add --clean flag to PyInstaller commands
- Change sidecar from --onefile to --onedir for faster startup
- Add missing hidden imports: onnxruntime, wave, huggingface_hub.utils

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 20:33:59 +08:00
egg
012cdaf5f3 fix: Add Chromium flags to fix audio capture in Electron
- Disable AudioServiceOutOfProcess feature which can cause audio capture issues
- Enable WebRTCPipeWireCapturer for better audio support
- Set autoplay-policy to no-user-gesture-required

These flags must be set before app is ready and help resolve
"Could not start audio source" errors in Electron while the
same microphone works fine in regular browsers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:18:48 +08:00
egg
66295f5177 fix: Filter out Stereo Mix and select real microphone device
- Filter out "立體聲混音" (Stereo Mix) devices which are not real microphones
- Skip the "default" device which may point to Stereo Mix
- Prefer "communications" device or devices with "麥克風/microphone" in label
- Use exact deviceId constraint for better device selection
- Add fallback to preferred constraint if exact fails
- Add more detailed console logging for debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 17:34:46 +08:00
egg
49dba2c43e fix: Improve microphone permission handling and audio capture robustness
- Add device enumeration check before attempting to capture audio
- Use simpler audio constraints (audio: true) instead of specific options
- Add fallback to explicit device ID if simple constraints fail
- Add more descriptive error messages for different failure modes
- Enhance Electron permission handlers with better logging
- Add setDevicePermissionHandler for audio device access
- Include 'microphone' in allowed permissions list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:47:09 +08:00
egg
e565951bf6 fix: Add microphone permission handlers for Electron
Added session.setPermissionRequestHandler and setPermissionCheckHandler
to automatically grant media/audio capture permissions. This fixes the
"could not start audio source" error when trying to start recording.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:54:44 +08:00
egg
744cfd1d27 fix: Add missing meeting_number column to table definitions
The meetings router was using meeting_number column but it was
not defined in the MYSQL_TABLES and SQLITE_TABLES schema definitions,
causing HTTP 500 errors when creating meetings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 14:47:51 +08:00
egg
81905688a6 fix: Escape parentheses in batch script echo statements
Parentheses in Chinese text (本地模式) and (雲端模式) were being
interpreted as if/else block delimiters, causing both echo statements
to execute regardless of the DATABASE_TYPE condition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 14:01:14 +08:00
egg
01d578c67e feat: Add SQLite database support and fixed portable extraction path
- Add SQLite as alternative database for offline/firewall environments
- Add --database-type parameter to build-client.bat (mysql/sqlite)
- Refactor database.py to support both MySQL and SQLite
- Add DB_TYPE and SQLITE_PATH configuration options
- Set fixed unpackDirName for portable exe (Meeting-Assistant)
- Update DEPLOYMENT.md with SQLite mode documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 13:57:02 +08:00
egg
34947f6262 fix: Handle UTF-8 BOM in config loading and build script
- main.js: Strip UTF-8 BOM when reading config.json
- build-client.bat: Write config.json without BOM using
  [System.Text.UTF8Encoding]::new($false)

PowerShell's -Encoding UTF8 writes BOM by default, which can
cause JSON parsing issues in Node.js.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 12:30:04 +08:00
egg
ac2d9a0240 chore: Set backend.embedded to true for all-in-one packaging
The embedded flag must be true for the portable exe to work.
When embedded is false, the app expects an external backend.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:28:56 +08:00
egg
2f80a4ac76 fix: Handle UTF-8 BOM in config.json
Use utf-8-sig encoding to handle Windows BOM (Byte Order Mark)
that may be present in config.json files edited with Notepad.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:14:54 +08:00
egg
6066946096 fix: Use direct env assignment in run_server.py
Changed from os.environ.setdefault() to direct os.environ[] assignment
for database, API, and auth config. This ensures config.json values
override any pre-existing environment variables (including empty ones
from .env files).

Also added debug output to help diagnose config loading issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 10:48:45 +08:00
egg
c9dc5839db fix: Correct hidden imports in backend build.py
- Change app.auth to app.routers.auth (correct path)
- Change app.routers.dify to app.routers.ai (actual module name)
- Change app.routers.excel to app.routers.export (actual module name)
- Remove app.routers.health (doesn't exist)
- Add app.models and app.models.schemas

Also add database credentials to config.json for embedded mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 10:33:47 +08:00
egg
58f379bc0c feat: Add embedded backend packaging for all-in-one deployment
- Add backend/run_server.py entry point for embedded deployment
- Add backend/build.py PyInstaller script for backend packaging
- Modify config.py to support frozen executable paths
- Extend client/config.json with backend configuration section
- Add backend sidecar management in Electron main process
- Add Whisper model download progress reporting
- Update build-client.bat with --embedded-backend flag
- Update DEPLOYMENT.md with all-in-one deployment documentation

This enables packaging frontend and backend into a single executable
for simplified enterprise deployment. Backward compatible with
existing separate deployment mode (backend.embedded: false).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 10:06:29 +08:00
egg
b1633fdcff feat: Display Whisper model status in frontend and add debug logging
- Add activeWhisperConfig tracking in main.js to expose current Whisper settings
- Expand get-sidecar-status IPC handler to return whisper config (model, device, compute, configSource)
- Add Whisper status display in meeting-detail.html transcript panel header
- Status updates every 5 seconds and shows: model, device, compute type, and ready state
- Add comprehensive debug logging for config loading and whisper config resolution
- Helps diagnose why config.json settings may not be passed correctly to sidecar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 07:43:35 +08:00
egg
3dd667197f fix: Change default Whisper model from small to medium
Align the default model in transcriber.py with config.json setting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 07:36:32 +08:00
egg
d3e3205692 docs: Update DEPLOYMENT.md with build scripts and API URL configuration
- Add one-click backend setup script documentation
- Document Windows build scripts with --api-url parameter
- Add runtime configuration options for config.json
- Include GitHub Actions CI/CD workflow instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 07:23:17 +08:00
egg
7075078d9e feat: Add --api-url parameter to build-client.bat
Now you can specify the backend API URL when building:
  build-client.bat build --api-url "http://192.168.1.100:8000/api"

This updates client/config.json before packaging so the exe
connects to the specified backend server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 07:19:52 +08:00
41 changed files with 5812 additions and 147 deletions

1
.gitignore vendored
View File

@@ -56,3 +56,4 @@ backend/record/
openspec/
AGENTS.md
CLAUDE.md
.running_pids

View File

@@ -4,7 +4,7 @@
- Python 3.10+
- Node.js 18+
- MySQL 8.0+
- MySQL 8.0+ 或 SQLite本地模式
- Access to Dify LLM service
## Quick Start
@@ -27,6 +27,31 @@ Use the startup script to run all services locally:
## Backend Deployment
### Quick Start (One-Click Setup)
使用一鍵設置腳本自動安裝依賴並啟動服務:
**Linux/macOS/WSL:**
```bash
./scripts/setup-backend.sh start
```
**Windows:**
```batch
.\scripts\setup-backend.bat start
```
**腳本命令:**
| 命令 | 說明 |
|------|------|
| `setup` | 僅設置環境(安裝依賴) |
| `start` | 設置並啟動後端服務(預設) |
| `stop` | 停止後端服務 |
| `--port PORT` | 指定服務端口(預設: 8000 |
| `--no-sidecar` | 不安裝 Sidecar 依賴 |
### Manual Setup
### 1. Setup Environment
```bash
@@ -104,47 +129,296 @@ sudo ./scripts/deploy-backend.sh install --port 8000
## Electron Client Deployment
### 1. Setup
### 1. Prerequisites
- **Windows**: Node.js 18+, Python 3.10+ (for building Sidecar)
- **Disk Space**: 5GB+ recommended (Whisper model + build artifacts)
### 2. Quick Build (Windows)
使用一鍵打包腳本在 Windows 上建置免安裝執行檔:
```batch
# 完整建置(使用預設 localhost
.\scripts\build-client.bat build --clean
# 指定後端 API URL
.\scripts\build-client.bat build --api-url "http://192.168.1.100:8000/api" --clean
# 使用公司伺服器
.\scripts\build-client.bat build --api-url "https://api.company.com/api"
# 僅打包 Electron已打包過 Sidecar
.\scripts\build-client.bat build --skip-sidecar --api-url "http://your-server:8000/api"
```
或使用 PowerShell
```powershell
.\scripts\build-all.ps1 -ApiUrl "http://192.168.1.100:8000/api" -Clean
```
**打包腳本參數:**
| 參數 | 說明 |
|------|------|
| `--api-url URL` | 後端 API URL會寫入 config.json |
| `--skip-sidecar` | 跳過 Sidecar 打包(已打包過時使用) |
| `--clean` | 建置前清理所有暫存檔案 |
### 3. Runtime Configuration
打包後的 exe 會讀取 `config.json` 中的設定:
```json
{
"apiBaseUrl": "http://localhost:8000/api",
"uploadTimeout": 600000,
"appTitle": "Meeting Assistant",
"whisper": {
"model": "medium",
"device": "cpu",
"compute": "int8"
}
}
```
**方式一**:打包時指定(推薦)
```batch
.\scripts\build-client.bat build --api-url "http://your-server:8000/api"
```
**方式二**:打包前手動編輯 `client/config.json`
**方式三**:打包後修改(適合測試)
- 執行檔旁邊的 `resources/config.json` 可在打包後修改
### 4. Manual Setup (Development)
```bash
cd client
# Install dependencies
npm install
```
### 2. Configure Environment
```bash
# Copy example and edit
cp .env.example .env
```
**Environment Variables:**
| Variable | Description | Default |
|----------|-------------|---------|
| `VITE_API_BASE_URL` | Backend API URL | http://localhost:8000/api |
| `VITE_UPLOAD_TIMEOUT` | Upload timeout (ms) | 600000 |
| `WHISPER_MODEL` | Whisper model size | medium |
| `WHISPER_DEVICE` | Execution device | cpu |
| `WHISPER_COMPUTE` | Compute precision | int8 |
### 3. Development
```bash
# Start in development mode
npm start
```
### 4. Build for Distribution
### 5. Build Output
```bash
# Update VITE_API_BASE_URL to production server first
# Then build portable executable
npm run build
建置完成後,輸出檔案位於:
- `client/dist/` - Electron 打包輸出
- `build/` - 最終整合輸出(含 exe
**輸出檔案:**
- `Meeting Assistant-1.0.0-portable.exe` - 免安裝執行檔
### 6. GitHub Actions (CI/CD)
也可以使用 GitHub Actions 自動建置:
1. 前往 GitHub Repository → Actions
2. 選擇 "Build Windows Client"
3. 點擊 "Run workflow"
4. 輸入 `api_url` 參數(例如 `http://192.168.1.100:8000/api`
5. 等待建置完成後下載 artifact
## All-in-One Deployment (全包部署模式)
此模式將前端、後端、Whisper 全部打包成單一執行檔,用戶雙擊即可使用,無需額外設置後端服務。
### 適用場景
- 企業內部部署,簡化用戶操作
- 無法獨立架設後端的環境
- 快速測試和演示
- **離線環境**:使用 SQLite 本地資料庫,無需網路連接資料庫
### 打包方式
```batch
# Windows 全包打包NSIS 安裝檔,推薦)
.\scripts\build-client.bat build --embedded-backend --clean
# Windows 全包打包SQLite 本地資料庫,適合離線/防火牆環境)
.\scripts\build-client.bat build --embedded-backend --database-type sqlite --clean
# Windows 全包打包Portable 免安裝,注意臨時資料夾限制)
.\scripts\build-client.bat build --embedded-backend --target portable --clean
```
The executable will be in `client/dist/`.
**打包參數說明:**
| 參數 | 說明 |
|------|------|
| `--embedded-backend` | 啟用內嵌後端模式 |
| `--database-type TYPE` | 資料庫類型:`mysql`(雲端)或 `sqlite`(本地) |
| `--target TARGET` | 打包目標:`nsis`(安裝檔,預設)或 `portable`(免安裝) |
| `--clean` | 建置前清理所有暫存檔案 |
### 打包目標比較
| 特性 | NSIS 安裝檔(推薦) | Portable 免安裝 |
|------|---------------------|-----------------|
| 安裝方式 | 執行安裝精靈 | 直接執行 |
| 資料持久性 | ✅ 安裝目錄內持久保存 | ⚠️ 臨時資料夾關閉後清空 |
| SQLite 位置 | 安裝目錄/data/ | %APPDATA%\Meeting-Assistant |
| 適用場景 | 正式部署 | 快速測試、展示 |
### config.json 配置
全包模式需要在 `config.json` 中配置資料庫和 API 金鑰:
#### MySQL 模式(雲端資料庫)
```json
{
"apiBaseUrl": "http://localhost:8000/api",
"uploadTimeout": 600000,
"appTitle": "Meeting Assistant",
"whisper": {
"model": "medium",
"device": "cpu",
"compute": "int8"
},
"backend": {
"embedded": true,
"host": "127.0.0.1",
"port": 8000,
"database": {
"type": "mysql",
"sqlitePath": "data/meeting.db",
"host": "mysql.theaken.com",
"port": 33306,
"user": "your_username",
"password": "your_password",
"database": "your_database"
},
"externalApis": {
"authApiUrl": "https://pj-auth-api.vercel.app/api/auth/login",
"difyApiUrl": "https://dify.theaken.com/v1",
"difyApiKey": "app-xxxxxxxxxx",
"difySttApiKey": "app-xxxxxxxxxx"
},
"auth": {
"adminEmail": "admin@example.com",
"jwtSecret": "your_secure_jwt_secret",
"jwtExpireHours": 24
}
}
}
```
#### SQLite 模式(本地資料庫)
適合離線環境或網路防火牆阻擋資料庫連線的情況:
```json
{
"apiBaseUrl": "http://localhost:8000/api",
"uploadTimeout": 600000,
"appTitle": "Meeting Assistant",
"whisper": {
"model": "medium",
"device": "cpu",
"compute": "int8"
},
"backend": {
"embedded": true,
"host": "127.0.0.1",
"port": 8000,
"database": {
"type": "sqlite",
"sqlitePath": "data/meeting.db"
},
"externalApis": {
"authApiUrl": "https://pj-auth-api.vercel.app/api/auth/login",
"difyApiUrl": "https://dify.theaken.com/v1",
"difyApiKey": "app-xxxxxxxxxx",
"difySttApiKey": "app-xxxxxxxxxx"
},
"auth": {
"adminEmail": "admin@example.com",
"jwtSecret": "your_secure_jwt_secret",
"jwtExpireHours": 24
}
}
}
```
> **注意**SQLite 資料庫位置會根據打包目標不同:
> - **NSIS 安裝檔**:安裝目錄內的 `data/meeting.db`
> - **Portable**`%APPDATA%\Meeting-Assistant\data\meeting.db`(持久保存,不會因關閉程式而清空)
### 配置說明
| 區段 | 欄位 | 說明 |
|------|------|------|
| `backend.embedded` | `true`/`false` | 啟用/停用內嵌後端模式 |
| `backend.host` | IP | 後端監聽地址(通常為 127.0.0.1 |
| `backend.port` | 數字 | 後端監聽端口(預設 8000 |
| `backend.database.type` | `mysql`/`sqlite` | 資料庫類型(預設 mysql |
| `backend.database.sqlitePath` | 路徑 | SQLite 資料庫檔案路徑(相對或絕對) |
| `backend.database.*` | 各欄位 | MySQL 資料庫連線資訊(僅 MySQL 模式需要) |
| `backend.externalApis.*` | 各欄位 | 外部 API 設定認證、Dify |
| `backend.auth.*` | 各欄位 | 認證設定管理員信箱、JWT 金鑰) |
### 資料庫模式比較
| 特性 | MySQL 模式 | SQLite 模式 |
|------|------------|-------------|
| 網路需求 | 需連接遠端資料庫 | 完全離線運作 |
| 資料位置 | 雲端資料庫伺服器 | 本機檔案 |
| 多用戶共享 | ✅ 支援 | ❌ 僅單機使用 |
| 適用場景 | 企業部署、多人共用 | 離線環境、防火牆限制 |
| 資料備份 | 使用資料庫工具 | 複製 `.db` 檔案即可 |
### Portable 執行檔說明
Portable exe 執行時會解壓縮到 `%TEMP%\Meeting-Assistant` 資料夾(固定路徑,非隨機資料夾)。
- **優點**Windows Defender 不會每次都提示警告
- **注意**:關閉程式後,臨時資料夾會被清空
- **SQLite 資料庫位置**:自動儲存到 `%APPDATA%\Meeting-Assistant\data\meeting.db`(不會被清空)
- **建議**:正式部署請使用 NSIS 安裝檔(`--target nsis`,預設)
### 啟動流程
1. 用戶雙擊 exe
2. 解壓縮到 `%TEMP%\Meeting-Assistant`
3. Electron 主程序啟動
4. 讀取 `config.json`
5. 啟動內嵌後端 (FastAPI)
6. 健康檢查等待後端就緒(最多 30 秒)
7. 後端就緒後,載入前端頁面
8. 啟動 Whisper Sidecar
9. 應用就緒
### 向後相容性
此功能完全向後相容,不影響既有部署方式:
| 部署方式 | config.json 設定 | 說明 |
|---------|------------------|------|
| 分離部署(預設) | `backend.embedded: false` | 前端連接遠端後端,使用 `apiBaseUrl` |
| 全包部署(新增) | `backend.embedded: true` | 前端內嵌後端,雙擊即可使用 |
### 安全注意事項
⚠️ **重要**:全包模式的 `config.json` 包含敏感資訊資料庫密碼、API 金鑰),請確保:
1. 不要將含有真實憑證的 config.json 提交到版本控制
2. 部署時由 IT 管理員配置敏感資訊
3. 考慮使用環境變數覆蓋敏感設定(環境變數優先級較高)
### Whisper 模型下載進度
首次運行時Whisper 模型(約 1.5GB)會自動下載。新版本會顯示下載進度:
- `⬇️ Downloading medium: 45% (675/1530 MB)` - 下載中
- `⏳ Loading medium...` - 載入模型中
- `✅ Ready` - 就緒
## Transcription Sidecar
@@ -185,6 +459,8 @@ Copy `sidecar/dist/` to `client/sidecar/` before building Electron app.
## Database Setup
### MySQL 模式
The backend will automatically create tables on first startup. To manually verify:
```sql
@@ -192,7 +468,20 @@ USE your_database;
SHOW TABLES LIKE 'meeting_%';
```
Expected tables:
### SQLite 模式
SQLite 資料庫檔案會在首次啟動時自動建立:
- **開發環境**`backend/data/meeting.db`
- **NSIS 安裝檔**:安裝目錄內的 `data/meeting.db`
- **Portable**`%APPDATA%\Meeting-Assistant\data\meeting.db`
**備份方式**:直接複製 `.db` 檔案即可。
> **注意**Portable 模式下SQLite 資料庫自動儲存到 `%APPDATA%` 以確保持久性(不會因關閉程式而清空)。
### Expected tables
- `meeting_users`
- `meeting_records`
- `meeting_conclusions`

View File

@@ -1,15 +1,52 @@
import os
import sys
from dotenv import load_dotenv
load_dotenv()
def get_base_dir() -> str:
"""Get base directory, supporting PyInstaller frozen executables."""
if getattr(sys, "frozen", False):
# Running as PyInstaller bundle
return os.path.dirname(sys.executable)
else:
# Running as script - go up two levels from app/config.py to backend/
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_app_data_dir() -> str:
"""Get persistent app data directory for storing user data.
This directory persists across application restarts, unlike temp folders
used by portable executables.
Returns:
Windows: %APPDATA%/Meeting-Assistant
macOS: ~/Library/Application Support/Meeting-Assistant
Linux: ~/.config/meeting-assistant
"""
if sys.platform == "win32":
# Windows: Use APPDATA
base = os.environ.get("APPDATA", os.path.expanduser("~"))
return os.path.join(base, "Meeting-Assistant")
elif sys.platform == "darwin":
# macOS: Use Application Support
return os.path.expanduser("~/Library/Application Support/Meeting-Assistant")
else:
# Linux: Use XDG config or fallback to ~/.config
xdg_config = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
return os.path.join(xdg_config, "meeting-assistant")
class Settings:
# Server Configuration
BACKEND_HOST: str = os.getenv("BACKEND_HOST", "0.0.0.0")
BACKEND_PORT: int = int(os.getenv("BACKEND_PORT", "8000"))
# Database Configuration
DB_TYPE: str = os.getenv("DB_TYPE", "mysql") # "mysql" or "sqlite"
SQLITE_PATH: str = os.getenv("SQLITE_PATH", "data/meeting.db")
DB_HOST: str = os.getenv("DB_HOST", "mysql.theaken.com")
DB_PORT: int = int(os.getenv("DB_PORT", "33306"))
DB_USER: str = os.getenv("DB_USER", "A060")
@@ -49,22 +86,63 @@ class Settings:
"""Return supported audio formats as a set."""
return set(self.SUPPORTED_AUDIO_FORMATS.split(","))
def get_template_dir(self, base_dir: str) -> str:
"""Get template directory path, resolving relative paths."""
def get_template_dir(self, base_dir: str | None = None) -> str:
"""Get template directory path, resolving relative paths.
Args:
base_dir: Base directory for relative paths. If None, uses get_base_dir()
which supports frozen executables.
"""
if base_dir is None:
base_dir = get_base_dir()
if self.TEMPLATE_DIR:
if os.path.isabs(self.TEMPLATE_DIR):
return self.TEMPLATE_DIR
return os.path.join(base_dir, self.TEMPLATE_DIR)
return os.path.join(base_dir, "template")
def get_record_dir(self, base_dir: str) -> str:
"""Get record directory path, resolving relative paths."""
def get_record_dir(self, base_dir: str | None = None) -> str:
"""Get record directory path, resolving relative paths.
Args:
base_dir: Base directory for relative paths. If None, uses get_base_dir()
which supports frozen executables.
"""
if base_dir is None:
base_dir = get_base_dir()
if self.RECORD_DIR:
if os.path.isabs(self.RECORD_DIR):
return self.RECORD_DIR
return os.path.join(base_dir, self.RECORD_DIR)
return os.path.join(base_dir, "record")
def get_sqlite_path(self, base_dir: str | None = None) -> str:
"""Get SQLite database file path, resolving relative paths.
For packaged executables (frozen), uses persistent app data directory
to survive portable exe cleanup. For development, uses relative path.
Args:
base_dir: Base directory for relative paths. If None, auto-detects.
"""
# If absolute path specified, use it directly
if self.SQLITE_PATH and os.path.isabs(self.SQLITE_PATH):
return self.SQLITE_PATH
# For frozen executables, use persistent app data directory
# This ensures SQLite data survives portable exe temp cleanup
if getattr(sys, "frozen", False):
app_data = get_app_data_dir()
db_name = os.path.basename(self.SQLITE_PATH) if self.SQLITE_PATH else "meeting.db"
return os.path.join(app_data, "data", db_name)
# For development, use relative path from base_dir
if base_dir is None:
base_dir = get_base_dir()
if self.SQLITE_PATH:
return os.path.join(base_dir, self.SQLITE_PATH)
return os.path.join(base_dir, "data", "meeting.db")
# Timeout helpers (convert ms to seconds for httpx)
@property
def upload_timeout_seconds(self) -> float:

View File

@@ -1,14 +1,59 @@
"""
Database abstraction layer supporting both MySQL and SQLite.
Usage:
from app.database import init_db, get_db_cursor, init_tables
# At application startup
init_db()
init_tables()
# In request handlers
with get_db_cursor() as cursor:
cursor.execute("SELECT * FROM meeting_records")
results = cursor.fetchall()
with get_db_cursor(commit=True) as cursor:
cursor.execute("INSERT INTO ...")
"""
import os
import sqlite3
import threading
from contextlib import contextmanager
import mysql.connector
from mysql.connector import pooling
from contextlib import contextmanager
from .config import settings
connection_pool = None
# Global state
_db_type: str = "mysql"
_mysql_pool = None
_sqlite_conn = None
_sqlite_lock = threading.Lock()
def init_db_pool():
global connection_pool
connection_pool = pooling.MySQLConnectionPool(
# ============================================================================
# Initialization Functions
# ============================================================================
def init_db():
"""Initialize database based on DB_TYPE setting."""
global _db_type
_db_type = settings.DB_TYPE.lower()
if _db_type == "sqlite":
init_sqlite()
else:
init_mysql()
def init_mysql():
"""Initialize MySQL connection pool."""
global _mysql_pool
_mysql_pool = pooling.MySQLConnectionPool(
pool_name="meeting_pool",
pool_size=settings.DB_POOL_SIZE,
host=settings.DB_HOST,
@@ -17,20 +62,125 @@ def init_db_pool():
password=settings.DB_PASS,
database=settings.DB_NAME,
)
return connection_pool
return _mysql_pool
def init_sqlite():
"""Initialize SQLite connection with row_factory for dict-like access."""
global _sqlite_conn
db_path = settings.get_sqlite_path()
db_dir = os.path.dirname(db_path)
# Create directory if needed
if db_dir and not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
_sqlite_conn = sqlite3.connect(db_path, check_same_thread=False)
_sqlite_conn.row_factory = sqlite3.Row
_sqlite_conn.execute("PRAGMA foreign_keys=ON")
print(f"SQLite database initialized at: {db_path}", flush=True)
return _sqlite_conn
# ============================================================================
# Legacy Compatibility
# ============================================================================
def init_db_pool():
"""Legacy function for backward compatibility. Use init_db() instead."""
return init_db()
# ============================================================================
# Connection Context Managers
# ============================================================================
@contextmanager
def get_db_connection():
conn = connection_pool.get_connection()
"""Get a database connection (MySQL or SQLite)."""
if _db_type == "sqlite":
# SQLite uses a single connection with thread lock
yield _sqlite_conn
else:
# MySQL uses connection pool
conn = _mysql_pool.get_connection()
try:
yield conn
finally:
conn.close()
class SQLiteCursorWrapper:
"""Wrapper to make SQLite cursor behave more like MySQL cursor with dictionary=True."""
def __init__(self, cursor):
self._cursor = cursor
self.lastrowid = None
self.rowcount = 0
def execute(self, query, params=None):
# Convert MySQL-style %s placeholders to SQLite ? placeholders
query = query.replace("%s", "?")
if params:
self._cursor.execute(query, params)
else:
self._cursor.execute(query)
self.lastrowid = self._cursor.lastrowid
self.rowcount = self._cursor.rowcount
def executemany(self, query, params_list):
query = query.replace("%s", "?")
self._cursor.executemany(query, params_list)
self.lastrowid = self._cursor.lastrowid
self.rowcount = self._cursor.rowcount
def fetchone(self):
row = self._cursor.fetchone()
if row is None:
return None
return dict(row)
def fetchall(self):
rows = self._cursor.fetchall()
return [dict(row) for row in rows]
def fetchmany(self, size=None):
if size:
rows = self._cursor.fetchmany(size)
else:
rows = self._cursor.fetchmany()
return [dict(row) for row in rows]
def close(self):
self._cursor.close()
@contextmanager
def get_db_cursor(commit=False):
"""Get a database cursor that returns dict-like rows.
Args:
commit: If True, commit the transaction after yield.
Yields:
cursor: A cursor that returns dict-like rows.
"""
if _db_type == "sqlite":
with _sqlite_lock:
cursor = SQLiteCursorWrapper(_sqlite_conn.cursor())
try:
yield cursor
if commit:
_sqlite_conn.commit()
except Exception:
_sqlite_conn.rollback()
raise
finally:
cursor.close()
else:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
try:
@@ -41,9 +191,11 @@ def get_db_cursor(commit=False):
cursor.close()
def init_tables():
"""Create all required tables if they don't exist."""
create_statements = [
# ============================================================================
# Table Initialization
# ============================================================================
MYSQL_TABLES = [
"""
CREATE TABLE IF NOT EXISTS meeting_users (
user_id INT PRIMARY KEY AUTO_INCREMENT,
@@ -57,6 +209,7 @@ def init_tables():
CREATE TABLE IF NOT EXISTS meeting_records (
meeting_id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(64) UNIQUE,
meeting_number VARCHAR(20),
subject VARCHAR(200) NOT NULL,
meeting_time DATETIME NOT NULL,
location VARCHAR(100),
@@ -91,6 +244,70 @@ def init_tables():
""",
]
with get_db_cursor(commit=True) as cursor:
for statement in create_statements:
SQLITE_TABLES = [
"""
CREATE TABLE IF NOT EXISTS meeting_users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
display_name TEXT,
role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_records (
meeting_id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE,
meeting_number TEXT,
subject TEXT NOT NULL,
meeting_time DATETIME NOT NULL,
location TEXT,
chairperson TEXT,
recorder TEXT,
attendees TEXT,
transcript_blob TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_conclusions (
conclusion_id INTEGER PRIMARY KEY AUTOINCREMENT,
meeting_id INTEGER,
content TEXT,
system_code TEXT,
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_action_items (
action_id INTEGER PRIMARY KEY AUTOINCREMENT,
meeting_id INTEGER,
content TEXT,
owner TEXT,
due_date DATE,
status TEXT CHECK(status IN ('Open', 'In Progress', 'Done', 'Delayed')) DEFAULT 'Open',
system_code TEXT,
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
]
def init_tables():
"""Create all required tables if they don't exist."""
tables = SQLITE_TABLES if _db_type == "sqlite" else MYSQL_TABLES
if _db_type == "sqlite":
with _sqlite_lock:
cursor = _sqlite_conn.cursor()
try:
for statement in tables:
cursor.execute(statement)
_sqlite_conn.commit()
finally:
cursor.close()
else:
with get_db_cursor(commit=True) as cursor:
for statement in tables:
cursor.execute(statement)

View File

@@ -1,9 +1,32 @@
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
from .database import init_db_pool, init_tables
from .routers import auth, meetings, ai, export
from .routers import auth, meetings, ai, export, sidecar
from .sidecar_manager import get_sidecar_manager
# Determine client directory path
# In development: backend/../client/src
# In packaged mode: backend/backend/_internal/../../client (relative to backend executable)
BACKEND_DIR = Path(__file__).parent.parent
PROJECT_DIR = BACKEND_DIR.parent
CLIENT_DIR = PROJECT_DIR / "client" / "src"
# Check for packaged mode (PyInstaller sets _MEIPASS)
import sys
if getattr(sys, 'frozen', False):
# Packaged mode: look for client folder relative to executable
# Backend runs from resources/backend/, client files at resources/backend/client/
EXEC_DIR = Path(sys.executable).parent.parent # up from backend/backend.exe
CLIENT_DIR = EXEC_DIR / "client"
print(f"[Backend] Packaged mode: CLIENT_DIR={CLIENT_DIR}")
else:
print(f"[Backend] Development mode: CLIENT_DIR={CLIENT_DIR}")
@asynccontextmanager
@@ -11,8 +34,25 @@ async def lifespan(app: FastAPI):
# Startup
init_db_pool()
init_tables()
# Only start sidecar in browser mode (not when Electron manages it)
# Set BROWSER_MODE=true in start-browser.sh to enable
browser_mode = os.environ.get("BROWSER_MODE", "").lower() == "true"
sidecar_mgr = get_sidecar_manager()
if browser_mode and sidecar_mgr.is_available():
print("[Backend] Browser mode: Starting sidecar...")
await sidecar_mgr.start()
elif browser_mode:
print("[Backend] Browser mode: Sidecar not available (transcription disabled)")
else:
print("[Backend] Electron mode: Sidecar managed by Electron")
yield
# Shutdown (cleanup if needed)
# Shutdown - only stop if we started it
if browser_mode:
sidecar_mgr.stop()
app = FastAPI(
@@ -36,9 +76,43 @@ app.include_router(auth.router, prefix="/api", tags=["Authentication"])
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
app.include_router(ai.router, prefix="/api", tags=["AI"])
app.include_router(export.router, prefix="/api", tags=["Export"])
app.include_router(sidecar.router, prefix="/api", tags=["Sidecar"])
@app.get("/api/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "service": "meeting-assistant"}
# ========================================
# Browser Mode: Serve static files
# ========================================
# Check if client directory exists for browser mode
print(f"[Backend] CLIENT_DIR exists: {CLIENT_DIR.exists()}")
if CLIENT_DIR.exists():
# Serve static assets (CSS, JS, etc.)
app.mount("/styles", StaticFiles(directory=CLIENT_DIR / "styles"), name="styles")
app.mount("/services", StaticFiles(directory=CLIENT_DIR / "services"), name="services")
app.mount("/config", StaticFiles(directory=CLIENT_DIR / "config"), name="config")
@app.get("/")
async def serve_login():
"""Serve login page."""
return FileResponse(CLIENT_DIR / "pages" / "login.html")
@app.get("/login")
async def serve_login_page():
"""Serve login page."""
return FileResponse(CLIENT_DIR / "pages" / "login.html")
@app.get("/meetings")
async def serve_meetings_page():
"""Serve meetings list page."""
return FileResponse(CLIENT_DIR / "pages" / "meetings.html")
@app.get("/meeting-detail")
async def serve_meeting_detail_page():
"""Serve meeting detail page."""
return FileResponse(CLIENT_DIR / "pages" / "meeting-detail.html")

View File

@@ -0,0 +1,346 @@
"""
Sidecar API Router
Provides HTTP endpoints for browser-based clients to access
the Whisper transcription sidecar functionality.
"""
import os
import tempfile
import base64
from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from ..sidecar_manager import get_sidecar_manager
router = APIRouter(prefix="/sidecar", tags=["Sidecar"])
class TranscribeRequest(BaseModel):
"""Request for transcribing audio from base64 data."""
audio_data: str # Base64 encoded audio (webm/opus)
class AudioChunkRequest(BaseModel):
"""Request for sending an audio chunk in streaming mode."""
data: str # Base64 encoded PCM audio
@router.get("/status")
async def get_sidecar_status():
"""
Get the current status of the sidecar transcription engine.
Returns:
Status object with ready state, whisper model info, etc.
"""
manager = get_sidecar_manager()
return manager.get_status()
@router.post("/start")
async def start_sidecar():
"""
Start the sidecar transcription engine.
This is typically called automatically on backend startup,
but can be used to restart the sidecar if needed.
"""
manager = get_sidecar_manager()
if not manager.is_available():
raise HTTPException(
status_code=503,
detail="Sidecar not available. Check if sidecar/transcriber.py and sidecar/venv exist."
)
success = await manager.start()
if not success:
raise HTTPException(
status_code=503,
detail="Failed to start sidecar. Check backend logs for details."
)
return {"status": "started", "ready": manager.ready}
@router.post("/stop")
async def stop_sidecar():
"""Stop the sidecar transcription engine."""
manager = get_sidecar_manager()
manager.stop()
return {"status": "stopped"}
@router.post("/transcribe")
async def transcribe_audio(request: TranscribeRequest):
"""
Transcribe base64-encoded audio data.
The audio should be in webm/opus format (as recorded by MediaRecorder).
"""
manager = get_sidecar_manager()
if not manager.ready:
raise HTTPException(
status_code=503,
detail="Sidecar not ready. Please wait for model to load."
)
try:
# Decode base64 audio
audio_data = base64.b64decode(request.audio_data)
# Save to temp file
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
f.write(audio_data)
temp_path = f.name
try:
# Transcribe
result = await manager.transcribe_file(temp_path)
if result.get("error"):
raise HTTPException(status_code=500, detail=result["error"])
return {
"result": result.get("result", ""),
"file": result.get("file", "")
}
finally:
# Clean up temp file
os.unlink(temp_path)
except base64.binascii.Error:
raise HTTPException(status_code=400, detail="Invalid base64 audio data")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/transcribe-file")
async def transcribe_audio_file(file: UploadFile = File(...)):
"""
Transcribe an uploaded audio file.
Accepts common audio formats: mp3, wav, m4a, webm, ogg, flac, aac
"""
manager = get_sidecar_manager()
if not manager.ready:
raise HTTPException(
status_code=503,
detail="Sidecar not ready. Please wait for model to load."
)
# Validate file extension
allowed_extensions = {".mp3", ".wav", ".m4a", ".webm", ".ogg", ".flac", ".aac"}
ext = os.path.splitext(file.filename or "")[1].lower()
if ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Unsupported audio format. Allowed: {', '.join(allowed_extensions)}"
)
try:
# Save uploaded file
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
content = await file.read()
f.write(content)
temp_path = f.name
try:
result = await manager.transcribe_file(temp_path)
if result.get("error"):
raise HTTPException(status_code=500, detail=result["error"])
return {
"result": result.get("result", ""),
"filename": file.filename
}
finally:
os.unlink(temp_path)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/stream/start")
async def start_streaming():
"""
Start a streaming transcription session.
Returns a session ID that should be used for subsequent audio chunks.
"""
manager = get_sidecar_manager()
if not manager.ready:
raise HTTPException(
status_code=503,
detail="Sidecar not ready. Please wait for model to load."
)
result = await manager.start_stream()
if result.get("error"):
raise HTTPException(status_code=500, detail=result["error"])
return result
@router.post("/stream/chunk")
async def send_audio_chunk(request: AudioChunkRequest):
"""
Send an audio chunk for streaming transcription.
The audio should be base64-encoded PCM data (16-bit, 16kHz, mono).
Returns a transcription segment if speech end was detected,
or null if more audio is needed.
"""
manager = get_sidecar_manager()
if not manager.ready:
raise HTTPException(
status_code=503,
detail="Sidecar not ready"
)
result = await manager.send_audio_chunk(request.data)
# Result may be None if no segment ready yet
if result is None:
return {"segment": None}
if result.get("error"):
raise HTTPException(status_code=500, detail=result["error"])
return {"segment": result}
@router.post("/stream/stop")
async def stop_streaming():
"""
Stop the streaming transcription session.
Returns any final transcription segments and session statistics.
"""
manager = get_sidecar_manager()
result = await manager.stop_stream()
if result.get("error"):
raise HTTPException(status_code=500, detail=result["error"])
return result
@router.post("/segment-audio")
async def segment_audio_file(file: UploadFile = File(...), max_chunk_seconds: int = 300):
"""
Segment an audio file using VAD for natural speech boundaries.
This is used for processing large audio files before cloud transcription.
Args:
file: The audio file to segment
max_chunk_seconds: Maximum duration per chunk (default 300s / 5 minutes)
Returns:
List of segment metadata with file paths
"""
manager = get_sidecar_manager()
if not manager.ready:
raise HTTPException(
status_code=503,
detail="Sidecar not ready. Please wait for model to load."
)
try:
# Save uploaded file
ext = os.path.splitext(file.filename or "")[1].lower() or ".wav"
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
content = await file.read()
f.write(content)
temp_path = f.name
try:
result = await manager.segment_audio(temp_path, max_chunk_seconds)
if result.get("error"):
raise HTTPException(status_code=500, detail=result["error"])
return result
finally:
# Keep temp file for now - segments reference it
# Will be cleaned up by the transcription process
pass
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""
WebSocket endpoint for real-time streaming transcription.
Protocol:
1. Client connects
2. Client sends: {"action": "start_stream"}
3. Server responds: {"status": "streaming", "session_id": "..."}
4. Client sends: {"action": "audio_chunk", "data": "<base64_pcm>"}
5. Server responds: {"segment": {...}} when speech detected, or {"segment": null}
6. Client sends: {"action": "stop_stream"}
7. Server responds: {"status": "stream_stopped", ...}
"""
await websocket.accept()
manager = get_sidecar_manager()
if not manager.ready:
await websocket.send_json({"error": "Sidecar not ready"})
await websocket.close()
return
try:
while True:
data = await websocket.receive_json()
action = data.get("action")
if action == "start_stream":
result = await manager.start_stream()
await websocket.send_json(result)
elif action == "audio_chunk":
audio_data = data.get("data")
if audio_data:
result = await manager.send_audio_chunk(audio_data)
await websocket.send_json({"segment": result})
else:
await websocket.send_json({"error": "No audio data"})
elif action == "stop_stream":
result = await manager.stop_stream()
await websocket.send_json(result)
break
elif action == "ping":
await websocket.send_json({"status": "pong"})
else:
await websocket.send_json({"error": f"Unknown action: {action}"})
except WebSocketDisconnect:
# Clean up streaming session if active
if manager._is_streaming():
await manager.stop_stream()
except Exception as e:
await websocket.send_json({"error": str(e)})
await websocket.close()

View File

@@ -0,0 +1,343 @@
"""
Sidecar Process Manager
Manages the Python sidecar process for speech-to-text transcription.
Provides an interface for the backend to communicate with the sidecar
via subprocess stdin/stdout.
"""
import asyncio
import json
import os
import subprocess
import sys
import tempfile
import base64
from pathlib import Path
from typing import Optional, Dict, Any, Callable
from threading import Thread, Lock
import queue
class SidecarManager:
"""
Manages the Whisper transcription sidecar process.
The sidecar is a Python process running transcriber.py that handles
speech-to-text conversion using faster-whisper.
"""
def __init__(self):
self.process: Optional[subprocess.Popen] = None
self.ready = False
self.whisper_info: Optional[Dict] = None
self._lock = Lock()
self._response_queue = queue.Queue()
self._reader_thread: Optional[Thread] = None
self._progress_callbacks: list[Callable] = []
self._last_status: Dict[str, Any] = {}
self._is_packaged = getattr(sys, 'frozen', False)
# Paths - detect packaged vs development mode
if self._is_packaged:
# Packaged mode: executable at resources/backend/backend/backend.exe
# Sidecar at resources/sidecar/transcriber/transcriber.exe
exec_dir = Path(sys.executable).parent.parent # up from backend/backend.exe
resources_dir = exec_dir.parent # up from backend/ to resources/
self.sidecar_dir = resources_dir / "sidecar" / "transcriber"
self.transcriber_path = self.sidecar_dir / ("transcriber.exe" if sys.platform == "win32" else "transcriber")
self.venv_python = None # Not used in packaged mode
print(f"[Sidecar] Packaged mode: transcriber={self.transcriber_path}")
else:
# Development mode
self.project_dir = Path(__file__).parent.parent.parent
self.sidecar_dir = self.project_dir / "sidecar"
self.transcriber_path = self.sidecar_dir / "transcriber.py"
if sys.platform == "win32":
self.venv_python = self.sidecar_dir / "venv" / "Scripts" / "python.exe"
else:
self.venv_python = self.sidecar_dir / "venv" / "bin" / "python"
print(f"[Sidecar] Development mode: transcriber={self.transcriber_path}")
def is_available(self) -> bool:
"""Check if sidecar is available (files exist)."""
if self._is_packaged:
# In packaged mode, just check the executable
available = self.transcriber_path.exists()
print(f"[Sidecar] is_available (packaged): {available}, path={self.transcriber_path}")
return available
else:
# Development mode - need both script and venv
available = self.transcriber_path.exists() and self.venv_python.exists()
print(f"[Sidecar] is_available (dev): {available}, script={self.transcriber_path.exists()}, venv={self.venv_python.exists()}")
return available
def get_status(self) -> Dict[str, Any]:
"""Get current sidecar status."""
return {
"ready": self.ready,
"streaming": self._is_streaming(),
"whisper": self.whisper_info,
"available": self.is_available(),
"browserMode": False,
**self._last_status
}
def _is_streaming(self) -> bool:
"""Check if currently in streaming mode."""
return self._last_status.get("streaming", False)
async def start(self) -> bool:
"""Start the sidecar process."""
if self.process and self.process.poll() is None:
return True # Already running
if not self.is_available():
return False
try:
# Get Whisper configuration from environment
env = os.environ.copy()
env["WHISPER_MODEL"] = os.getenv("WHISPER_MODEL", "medium")
env["WHISPER_DEVICE"] = os.getenv("WHISPER_DEVICE", "cpu")
env["WHISPER_COMPUTE"] = os.getenv("WHISPER_COMPUTE", "int8")
print(f"[Sidecar] Starting with model={env['WHISPER_MODEL']}, device={env['WHISPER_DEVICE']}, compute={env['WHISPER_COMPUTE']}")
# Build command based on mode
if self._is_packaged:
# Packaged mode: run the executable directly
cmd = [str(self.transcriber_path)]
cwd = str(self.sidecar_dir)
else:
# Development mode: use venv python
cmd = [str(self.venv_python), str(self.transcriber_path), "--server"]
cwd = str(self.sidecar_dir.parent) if self._is_packaged else str(self.sidecar_dir)
print(f"[Sidecar] Command: {cmd}, cwd={cwd}")
self.process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
cwd=cwd,
bufsize=1, # Line buffered
text=True
)
# Start reader threads
self._reader_thread = Thread(target=self._read_stdout, daemon=True)
self._reader_thread.start()
stderr_thread = Thread(target=self._read_stderr, daemon=True)
stderr_thread.start()
# Wait for ready signal
try:
response = await asyncio.wait_for(
asyncio.get_event_loop().run_in_executor(
None, self._wait_for_ready
),
timeout=120.0 # 2 minutes for model download
)
if response and response.get("status") == "ready":
self.ready = True
print("[Sidecar] Ready")
return True
except asyncio.TimeoutError:
print("[Sidecar] Timeout waiting for ready")
self.stop()
return False
except Exception as e:
print(f"[Sidecar] Start error: {e}")
return False
return False
def _wait_for_ready(self) -> Optional[Dict]:
"""Wait for the ready signal from sidecar."""
while True:
try:
response = self._response_queue.get(timeout=1.0)
status = response.get("status", "")
# Track progress events
if status in ["downloading_model", "model_downloaded", "model_cached",
"loading_model", "model_loaded", "model_error"]:
self._last_status = response
self._notify_progress(response)
if status == "model_loaded":
# Extract whisper info
self.whisper_info = {
"model": os.getenv("WHISPER_MODEL", "medium"),
"device": os.getenv("WHISPER_DEVICE", "cpu"),
"compute": os.getenv("WHISPER_COMPUTE", "int8"),
"configSource": "environment"
}
elif status == "model_error":
self.whisper_info = {"error": response.get("error", "Unknown error")}
if status == "ready":
return response
except queue.Empty:
if self.process and self.process.poll() is not None:
return None # Process died
continue
def _read_stdout(self):
"""Read stdout from sidecar process."""
if not self.process or not self.process.stdout:
return
for line in self.process.stdout:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
self._response_queue.put(data)
except json.JSONDecodeError as e:
print(f"[Sidecar] Invalid JSON: {line[:100]}")
def _read_stderr(self):
"""Read stderr from sidecar process."""
if not self.process or not self.process.stderr:
return
for line in self.process.stderr:
line = line.strip()
if line:
# Try to parse as JSON (some status messages go to stderr)
try:
data = json.loads(line)
if "status" in data or "warning" in data:
self._notify_progress(data)
except json.JSONDecodeError:
print(f"[Sidecar stderr] {line}")
def _notify_progress(self, data: Dict):
"""Notify all progress callbacks."""
for callback in self._progress_callbacks:
try:
callback(data)
except Exception as e:
print(f"[Sidecar] Progress callback error: {e}")
def add_progress_callback(self, callback: Callable):
"""Add a callback for progress updates."""
self._progress_callbacks.append(callback)
def remove_progress_callback(self, callback: Callable):
"""Remove a progress callback."""
if callback in self._progress_callbacks:
self._progress_callbacks.remove(callback)
async def send_command(self, command: Dict) -> Optional[Dict]:
"""Send a command to the sidecar and wait for response."""
if not self.process or self.process.poll() is not None:
return {"error": "Sidecar not running"}
with self._lock:
try:
# Clear queue before sending
while not self._response_queue.empty():
try:
self._response_queue.get_nowait()
except queue.Empty:
break
# Send command
cmd_json = json.dumps(command) + "\n"
self.process.stdin.write(cmd_json)
self.process.stdin.flush()
# Wait for response
try:
response = await asyncio.wait_for(
asyncio.get_event_loop().run_in_executor(
None, lambda: self._response_queue.get(timeout=60.0)
),
timeout=65.0
)
return response
except (asyncio.TimeoutError, queue.Empty):
return {"error": "Command timeout"}
except Exception as e:
return {"error": f"Command error: {e}"}
async def transcribe_file(self, audio_path: str) -> Dict:
"""Transcribe an audio file."""
return await self.send_command({
"action": "transcribe",
"file": audio_path
}) or {"error": "No response"}
async def start_stream(self) -> Dict:
"""Start a streaming transcription session."""
result = await self.send_command({"action": "start_stream"})
if result and result.get("status") == "streaming":
self._last_status["streaming"] = True
return result or {"error": "No response"}
async def send_audio_chunk(self, base64_audio: str) -> Optional[Dict]:
"""Send an audio chunk for streaming transcription."""
return await self.send_command({
"action": "audio_chunk",
"data": base64_audio
})
async def stop_stream(self) -> Dict:
"""Stop the streaming session."""
result = await self.send_command({"action": "stop_stream"})
self._last_status["streaming"] = False
return result or {"error": "No response"}
async def segment_audio(self, file_path: str, max_chunk_seconds: int = 300) -> Dict:
"""Segment an audio file using VAD."""
return await self.send_command({
"action": "segment_audio",
"file_path": file_path,
"max_chunk_seconds": max_chunk_seconds
}) or {"error": "No response"}
def stop(self):
"""Stop the sidecar process."""
self.ready = False
self._last_status = {}
if self.process:
try:
# Try graceful shutdown
self.process.stdin.write('{"action": "quit"}\n')
self.process.stdin.flush()
self.process.wait(timeout=5.0)
except:
pass
finally:
if self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=2.0)
except:
self.process.kill()
self.process = None
print("[Sidecar] Stopped")
# Global instance
_sidecar_manager: Optional[SidecarManager] = None
def get_sidecar_manager() -> SidecarManager:
"""Get or create the global sidecar manager instance."""
global _sidecar_manager
if _sidecar_manager is None:
_sidecar_manager = SidecarManager()
return _sidecar_manager

144
backend/build.py Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Build script for creating standalone backend executable using PyInstaller.
Uses --onedir mode for faster startup compared to --onefile.
"""
import subprocess
import sys
import os
import shutil
def clean_build_cache(script_dir):
"""Clean old build artifacts that may cause stale spec file issues."""
dirs_to_clean = [
os.path.join(script_dir, "build"),
os.path.join(script_dir, "__pycache__"),
]
files_to_clean = [
os.path.join(script_dir, "build", "backend.spec"),
]
for f in files_to_clean:
if os.path.exists(f):
print(f"Removing old spec file: {f}")
os.remove(f)
for d in dirs_to_clean:
pycache = os.path.join(d)
if os.path.exists(pycache) and "__pycache__" in pycache:
print(f"Removing cache: {pycache}")
shutil.rmtree(pycache)
def build():
"""Build the backend executable."""
script_dir = os.path.dirname(os.path.abspath(__file__))
# Clean old build cache to avoid stale spec file issues
clean_build_cache(script_dir)
# PyInstaller command with --onedir for faster startup
cmd = [
sys.executable, "-m", "PyInstaller",
"--onedir",
"--clean", # Clean PyInstaller cache before building
"--name", "backend",
"--distpath", "dist",
"--workpath", "build",
"--specpath", "build",
# FastAPI and web framework
"--hidden-import", "uvicorn",
"--hidden-import", "uvicorn.logging",
"--hidden-import", "uvicorn.loops",
"--hidden-import", "uvicorn.loops.auto",
"--hidden-import", "uvicorn.protocols",
"--hidden-import", "uvicorn.protocols.http",
"--hidden-import", "uvicorn.protocols.http.auto",
"--hidden-import", "uvicorn.protocols.websockets",
"--hidden-import", "uvicorn.protocols.websockets.auto",
"--hidden-import", "uvicorn.lifespan",
"--hidden-import", "uvicorn.lifespan.on",
"--hidden-import", "uvicorn.lifespan.off",
"--hidden-import", "fastapi",
"--hidden-import", "starlette",
"--hidden-import", "pydantic",
"--hidden-import", "pydantic_core",
# Database - MySQL
"--hidden-import", "mysql.connector",
"--hidden-import", "mysql.connector.pooling",
# Database - SQLite (built-in, but ensure it's included)
"--hidden-import", "sqlite3",
# HTTP client
"--hidden-import", "httpx",
"--hidden-import", "httpcore",
# Authentication
"--hidden-import", "jose",
"--hidden-import", "jose.jwt",
"--hidden-import", "cryptography",
# Excel export
"--hidden-import", "openpyxl",
# Multipart form handling
"--hidden-import", "multipart",
"--hidden-import", "python_multipart",
# Environment loading
"--hidden-import", "dotenv",
# Timezone data
"--hidden-import", "tzdata",
# Application modules - only include modules that exist
"--hidden-import", "app",
"--hidden-import", "app.main",
"--hidden-import", "app.config",
"--hidden-import", "app.database",
"--hidden-import", "app.routers",
"--hidden-import", "app.routers.auth",
"--hidden-import", "app.routers.meetings",
"--hidden-import", "app.routers.ai",
"--hidden-import", "app.routers.export",
"--hidden-import", "app.routers.sidecar",
"--hidden-import", "app.sidecar_manager",
"--hidden-import", "app.models",
"--hidden-import", "app.models.schemas",
# Collect package data
"--collect-data", "pydantic",
"--collect-data", "uvicorn",
"run_server.py"
]
print("Building backend executable...")
print(f"Command: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=script_dir)
if result.returncode != 0:
print("\nBuild failed!")
sys.exit(1)
# Copy template directory to dist
template_src = os.path.join(script_dir, "template")
template_dst = os.path.join(script_dir, "dist", "backend", "template")
if os.path.exists(template_src):
print(f"\nCopying template directory to {template_dst}...")
if os.path.exists(template_dst):
shutil.rmtree(template_dst)
shutil.copytree(template_src, template_dst)
print("Template directory copied successfully.")
else:
print(f"\nWarning: Template directory not found at {template_src}")
# Create empty record directory
record_dst = os.path.join(script_dir, "dist", "backend", "record")
os.makedirs(record_dst, exist_ok=True)
print(f"Created record directory at {record_dst}")
print("\nBuild successful!")
print(f"Executable created at: dist/backend/backend.exe (Windows) or dist/backend/backend (Linux)")
print("\nTo run:")
print(" 1. Copy config.json to dist/backend/")
print(" 2. Run: dist/backend/backend.exe (Windows) or ./dist/backend/backend (Linux)")
if __name__ == "__main__":
build()

View File

@@ -7,5 +7,6 @@ httpx>=0.27.0
python-multipart>=0.0.9
python-jose[cryptography]>=3.3.0
openpyxl>=3.1.2
tzdata>=2024.1
pytest>=8.0.0
pytest-asyncio>=0.24.0

165
backend/run_server.py Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
Backend entry point for embedded deployment.
Loads configuration from config.json and starts uvicorn server.
"""
import json
import os
import sys
def get_base_dir() -> str:
"""Get base directory, supporting PyInstaller frozen executables."""
if getattr(sys, "frozen", False):
# Running as PyInstaller bundle
return os.path.dirname(sys.executable)
else:
# Running as script
return os.path.dirname(os.path.abspath(__file__))
def load_config(config_path: str | None = None) -> dict:
"""Load configuration from config.json file."""
if config_path is None:
base_dir = get_base_dir()
config_path = os.path.join(base_dir, "config.json")
if os.path.exists(config_path):
# Use utf-8-sig to handle Windows BOM (Byte Order Mark)
with open(config_path, "r", encoding="utf-8-sig") as f:
return json.load(f)
return {}
def apply_config_to_env(config: dict) -> None:
"""
Apply config.json values to environment variables.
Environment variables take precedence (already set values are not overwritten).
"""
backend_config = config.get("backend", {})
# Server configuration
if "host" in backend_config:
os.environ.setdefault("BACKEND_HOST", backend_config["host"])
if "port" in backend_config:
os.environ.setdefault("BACKEND_PORT", str(backend_config["port"]))
# Database configuration - use direct assignment to ensure config values are used
db_config = backend_config.get("database", {})
if "type" in db_config:
os.environ["DB_TYPE"] = db_config["type"]
if "sqlitePath" in db_config:
os.environ["SQLITE_PATH"] = db_config["sqlitePath"]
if "host" in db_config:
os.environ["DB_HOST"] = db_config["host"]
if "port" in db_config:
os.environ["DB_PORT"] = str(db_config["port"])
if "user" in db_config:
os.environ["DB_USER"] = db_config["user"]
if "password" in db_config:
os.environ["DB_PASS"] = db_config["password"]
if "database" in db_config:
os.environ["DB_NAME"] = db_config["database"]
if "poolSize" in db_config:
os.environ["DB_POOL_SIZE"] = str(db_config["poolSize"])
# External API configuration - use direct assignment
api_config = backend_config.get("externalApis", {})
if "authApiUrl" in api_config:
os.environ["AUTH_API_URL"] = api_config["authApiUrl"]
if "difyApiUrl" in api_config:
os.environ["DIFY_API_URL"] = api_config["difyApiUrl"]
if "difyApiKey" in api_config:
os.environ["DIFY_API_KEY"] = api_config["difyApiKey"]
if "difySttApiKey" in api_config:
os.environ["DIFY_STT_API_KEY"] = api_config["difySttApiKey"]
# Authentication configuration - use direct assignment
auth_config = backend_config.get("auth", {})
if "adminEmail" in auth_config:
os.environ["ADMIN_EMAIL"] = auth_config["adminEmail"]
if "jwtSecret" in auth_config:
os.environ["JWT_SECRET"] = auth_config["jwtSecret"]
if "jwtExpireHours" in auth_config:
os.environ["JWT_EXPIRE_HOURS"] = str(auth_config["jwtExpireHours"])
# File configuration - set TEMPLATE_DIR and RECORD_DIR relative to base
base_dir = get_base_dir()
if not os.environ.get("TEMPLATE_DIR"):
template_dir = os.path.join(base_dir, "template")
if os.path.exists(template_dir):
os.environ["TEMPLATE_DIR"] = template_dir
if not os.environ.get("RECORD_DIR"):
record_dir = os.path.join(base_dir, "record")
os.makedirs(record_dir, exist_ok=True)
os.environ["RECORD_DIR"] = record_dir
def main():
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description="Meeting Assistant Backend Server")
parser.add_argument(
"--config",
type=str,
help="Path to config.json file",
)
parser.add_argument(
"--host",
type=str,
help="Host to bind to (overrides config)",
)
parser.add_argument(
"--port",
type=int,
help="Port to bind to (overrides config)",
)
args = parser.parse_args()
# Load and apply configuration
config = load_config(args.config)
# Debug: print loaded config
print(f"DEBUG: Config path: {args.config}", flush=True)
print(f"DEBUG: Loaded config keys: {list(config.keys())}", flush=True)
backend_config = config.get("backend", {})
db_config = backend_config.get("database", {})
print(f"DEBUG: DB type={db_config.get('type', 'mysql')}", flush=True)
print(f"DEBUG: DB config: host={db_config.get('host')}, user={db_config.get('user')}, pass={'***' if db_config.get('password') else 'EMPTY'}", flush=True)
apply_config_to_env(config)
# Debug: print env vars after setting
print(f"DEBUG: ENV DB_TYPE={os.environ.get('DB_TYPE', 'mysql')}", flush=True)
print(f"DEBUG: ENV DB_HOST={os.environ.get('DB_HOST')}", flush=True)
print(f"DEBUG: ENV DB_USER={os.environ.get('DB_USER')}", flush=True)
print(f"DEBUG: ENV DB_PASS={'***' if os.environ.get('DB_PASS') else 'EMPTY'}", flush=True)
# Command line arguments override everything
if args.host:
os.environ["BACKEND_HOST"] = args.host
if args.port:
os.environ["BACKEND_PORT"] = str(args.port)
# Get final host/port values
host = os.environ.get("BACKEND_HOST", "127.0.0.1")
port = int(os.environ.get("BACKEND_PORT", "8000"))
print(f"Starting backend server on {host}:{port}", flush=True)
# Import and run uvicorn
import uvicorn
uvicorn.run(
"app.main:app",
host=host,
port=port,
log_level="info",
)
if __name__ == "__main__":
main()

View File

@@ -2,9 +2,37 @@
"apiBaseUrl": "http://localhost:8000/api",
"uploadTimeout": 600000,
"appTitle": "Meeting Assistant",
"ui": {
"launchBrowser": true
},
"whisper": {
"model": "medium",
"device": "cpu",
"compute": "int8"
},
"backend": {
"embedded": true,
"host": "127.0.0.1",
"port": 8000,
"database": {
"type": "mysql",
"sqlitePath": "data/meeting.db",
"host": "mysql.theaken.com",
"port": 33306,
"user": "A060",
"password": "WLeSCi0yhtc7",
"database": "db_A060"
},
"externalApis": {
"authApiUrl": "https://pj-auth-api.vercel.app/api/auth/login",
"difyApiUrl": "https://dify.theaken.com/v1",
"difyApiKey": "app-oFptWFRlSgvwhJ8DzZKN08a0",
"difySttApiKey": "app-xQeSipaQecs0cuKeLvYDaRsu"
},
"auth": {
"adminEmail": "ymirliu@panjit.com.tw",
"jwtSecret": "your_jwt_secret_here",
"jwtExpireHours": 24
}
}
}

View File

@@ -33,29 +33,58 @@
"to": "sidecar/transcriber",
"filter": ["**/*"]
},
{
"from": "../backend/dist/backend",
"to": "backend/backend",
"filter": ["**/*"]
},
{
"from": "config.json",
"to": "config.json"
},
{
"from": "src/pages",
"to": "backend/client/pages",
"filter": ["**/*"]
},
{
"from": "src/styles",
"to": "backend/client/styles",
"filter": ["**/*"]
},
{
"from": "src/services",
"to": "backend/client/services",
"filter": ["**/*"]
},
{
"from": "src/config",
"to": "backend/client/config",
"filter": ["**/*"]
}
],
"win": {
"target": [
{
"target": "portable",
"target": "nsis",
"arch": ["x64"]
}
],
"icon": "assets/icon.ico",
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "${productName}-${version}-setup.${ext}",
"deleteAppDataOnUninstall": false
},
"mac": {
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
}
],
"icon": "assets/icon.icns"
]
},
"linux": {
"target": [
@@ -63,11 +92,11 @@
"target": "AppImage",
"arch": ["x64"]
}
],
"icon": "assets/icon.png"
]
},
"portable": {
"artifactName": "${productName}-${version}-portable.${ext}"
"artifactName": "${productName}-${version}-portable.${ext}",
"unpackDirName": "Meeting-Assistant"
}
}
}

View File

@@ -1,14 +1,25 @@
const { app, BrowserWindow, ipcMain } = require("electron");
const { app, BrowserWindow, ipcMain, session, shell } = require("electron");
const path = require("path");
const fs = require("fs");
const { spawn } = require("child_process");
const os = require("os");
// Chromium flags to fix audio capture issues in Electron
// Must be set before app is ready
app.commandLine.appendSwitch("disable-features", "AudioServiceOutOfProcess");
app.commandLine.appendSwitch("enable-features", "WebRTCPipeWireCapturer");
app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");
let mainWindow;
let sidecarProcess;
let sidecarReady = false;
let streamingActive = false;
let appConfig = null;
let activeWhisperConfig = null;
// Backend sidecar state
let backendProcess = null;
let backendReady = false;
/**
* Load configuration from external config.json
@@ -17,6 +28,11 @@ let appConfig = null;
* - Packaged: <app>/resources/config.json
*/
function loadConfig() {
console.log("=== Config Loading Debug ===");
console.log("app.isPackaged:", app.isPackaged);
console.log("process.resourcesPath:", process.resourcesPath);
console.log("__dirname:", __dirname);
const configPaths = [
// Packaged app: resources folder
app.isPackaged ? path.join(process.resourcesPath, "config.json") : null,
@@ -26,13 +42,23 @@ function loadConfig() {
path.join(__dirname, "config.json"),
].filter(Boolean);
console.log("Config search paths:", configPaths);
for (const configPath of configPaths) {
const exists = fs.existsSync(configPath);
console.log(`Checking: ${configPath} - exists: ${exists}`);
try {
if (fs.existsSync(configPath)) {
const configData = fs.readFileSync(configPath, "utf-8");
if (exists) {
// Use utf-8 and strip BOM if present (Windows Notepad adds BOM)
let configData = fs.readFileSync(configPath, "utf-8");
// Remove UTF-8 BOM if present
if (configData.charCodeAt(0) === 0xFEFF) {
configData = configData.slice(1);
}
appConfig = JSON.parse(configData);
console.log("Config loaded from:", configPath);
console.log("Config:", appConfig);
console.log("Config content:", JSON.stringify(appConfig, null, 2));
console.log("Whisper config:", appConfig.whisper);
return appConfig;
}
} catch (error) {
@@ -51,10 +77,194 @@ function loadConfig() {
compute: "int8"
}
};
console.log("Using default config:", appConfig);
console.log("WARNING: No config.json found, using defaults");
console.log("Default config:", JSON.stringify(appConfig, null, 2));
return appConfig;
}
/**
* Get config.json path for backend sidecar
*/
function getConfigPath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, "config.json");
}
return path.join(__dirname, "..", "config.json");
}
/**
* Start backend sidecar process (FastAPI server)
* Only starts if backend.embedded is true in config
*/
function startBackendSidecar() {
const backendConfig = appConfig?.backend || {};
// Check if embedded backend is enabled
if (!backendConfig.embedded) {
console.log("Backend embedded mode disabled, using remote backend at:", appConfig?.apiBaseUrl);
backendReady = true; // Assume remote backend is ready
return;
}
console.log("Starting embedded backend sidecar...");
const backendDir = app.isPackaged
? path.join(process.resourcesPath, "backend")
: path.join(__dirname, "..", "..", "backend");
// Determine the backend executable path
let backendExecutable;
let backendArgs = [];
if (app.isPackaged) {
// Packaged app: use PyInstaller-built executable
if (process.platform === "win32") {
backendExecutable = path.join(backendDir, "backend", "backend.exe");
} else {
backendExecutable = path.join(backendDir, "backend", "backend");
}
// Pass config path
backendArgs = ["--config", getConfigPath()];
} else {
// Development mode: use Python script with venv
const backendScript = path.join(backendDir, "run_server.py");
if (!fs.existsSync(backendScript)) {
console.log("Backend script not found at:", backendScript);
console.log("Backend sidecar will not be available.");
return;
}
// Check for virtual environment Python
let venvPython;
if (process.platform === "win32") {
venvPython = path.join(backendDir, "venv", "Scripts", "python.exe");
} else {
venvPython = path.join(backendDir, "venv", "bin", "python");
}
backendExecutable = fs.existsSync(venvPython) ? venvPython : "python3";
backendArgs = [backendScript, "--config", getConfigPath()];
}
if (!fs.existsSync(backendExecutable) && app.isPackaged) {
console.log("Backend executable not found at:", backendExecutable);
console.log("Backend sidecar will not be available.");
return;
}
try {
console.log("Starting backend with:", backendExecutable, backendArgs.join(" "));
backendProcess = spawn(backendExecutable, backendArgs, {
cwd: backendDir,
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
});
backendProcess.stdout.on("data", (data) => {
console.log("Backend:", data.toString().trim());
});
backendProcess.stderr.on("data", (data) => {
console.log("Backend:", data.toString().trim());
});
backendProcess.on("close", (code) => {
console.log(`Backend exited with code ${code}`);
backendReady = false;
backendProcess = null;
});
backendProcess.on("error", (err) => {
console.error("Backend error:", err.message);
backendReady = false;
});
} catch (error) {
console.error("Failed to start backend:", error);
}
}
/**
* Wait for backend to be ready by polling health endpoint
* @param {number} maxAttempts - Maximum number of attempts (default 30)
* @param {number} intervalMs - Interval between attempts in ms (default 1000)
* @returns {Promise<boolean>} - True if backend is ready, false if timeout
*/
async function waitForBackendReady(maxAttempts = 30, intervalMs = 1000) {
const backendConfig = appConfig?.backend || {};
// If embedded mode is disabled, assume backend is ready
if (!backendConfig.embedded) {
backendReady = true;
return true;
}
const host = backendConfig.host || "127.0.0.1";
const port = backendConfig.port || 8000;
const healthUrl = `http://${host}:${port}/api/health`;
console.log(`Waiting for backend at ${healthUrl}...`);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const http = require("http");
const ready = await new Promise((resolve) => {
const req = http.get(healthUrl, (res) => {
resolve(res.statusCode === 200);
});
req.on("error", () => resolve(false));
req.setTimeout(2000, () => {
req.destroy();
resolve(false);
});
});
if (ready) {
console.log(`Backend ready after ${attempt} attempt(s)`);
backendReady = true;
return true;
}
} catch (e) {
// Ignore errors, will retry
}
console.log(`Backend health check attempt ${attempt}/${maxAttempts} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
console.error(`Backend did not become ready after ${maxAttempts} attempts`);
return false;
}
/**
* Stop backend sidecar process
*/
function stopBackendSidecar() {
if (!backendProcess) {
return;
}
console.log("Stopping backend sidecar...");
// Send SIGTERM first
backendProcess.kill("SIGTERM");
// Force kill after 5 seconds if still running
const forceKillTimeout = setTimeout(() => {
if (backendProcess) {
console.log("Force killing backend sidecar...");
backendProcess.kill("SIGKILL");
}
}, 5000);
backendProcess.on("close", () => {
clearTimeout(forceKillTimeout);
backendProcess = null;
backendReady = false;
});
}
function createWindow() {
// Set window title from config
const windowTitle = appConfig?.appTitle || "Meeting Assistant";
@@ -78,9 +288,12 @@ function createWindow() {
}
function startSidecar() {
console.log("=== startSidecar() called ===");
const sidecarDir = app.isPackaged
? path.join(process.resourcesPath, "sidecar")
: path.join(__dirname, "..", "..", "sidecar");
console.log("Sidecar directory:", sidecarDir);
console.log("App is packaged:", app.isPackaged);
// Determine the sidecar executable path based on packaging and platform
let sidecarExecutable;
@@ -117,15 +330,25 @@ function startSidecar() {
sidecarArgs = [sidecarScript];
}
console.log("Checking sidecar executable at:", sidecarExecutable);
if (!fs.existsSync(sidecarExecutable)) {
console.log("Sidecar executable not found at:", sidecarExecutable);
console.log("ERROR: Sidecar executable not found at:", sidecarExecutable);
console.log("Transcription will not be available.");
return;
}
console.log("Sidecar executable found:", sidecarExecutable);
try {
// Get Whisper configuration from config.json or environment variables
console.log("=== Whisper Config Resolution ===");
console.log("appConfig:", appConfig);
console.log("appConfig?.whisper:", appConfig?.whisper);
const whisperConfig = appConfig?.whisper || {};
console.log("whisperConfig (resolved):", whisperConfig);
console.log("process.env.WHISPER_MODEL:", process.env.WHISPER_MODEL);
console.log("whisperConfig.model:", whisperConfig.model);
const whisperEnv = {
...process.env,
WHISPER_MODEL: process.env.WHISPER_MODEL || whisperConfig.model || "medium",
@@ -133,12 +356,20 @@ function startSidecar() {
WHISPER_COMPUTE: process.env.WHISPER_COMPUTE || whisperConfig.compute || "int8",
};
console.log("Starting sidecar with:", sidecarExecutable, sidecarArgs.join(" "));
console.log("Whisper config:", {
// Store the active whisper config for status reporting
activeWhisperConfig = {
model: whisperEnv.WHISPER_MODEL,
device: whisperEnv.WHISPER_DEVICE,
compute: whisperEnv.WHISPER_COMPUTE,
});
configSource: appConfig?.whisper ? "config.json" : "defaults"
};
console.log("=== Final Whisper Environment ===");
console.log("WHISPER_MODEL:", whisperEnv.WHISPER_MODEL);
console.log("WHISPER_DEVICE:", whisperEnv.WHISPER_DEVICE);
console.log("WHISPER_COMPUTE:", whisperEnv.WHISPER_COMPUTE);
console.log("Starting sidecar with:", sidecarExecutable, sidecarArgs.join(" "));
console.log("Active Whisper config:", activeWhisperConfig);
sidecarProcess = spawn(sidecarExecutable, sidecarArgs, {
cwd: sidecarDir,
@@ -177,6 +408,47 @@ function startSidecar() {
if (msg.result !== undefined && mainWindow) {
mainWindow.webContents.send("transcription-result", msg.result);
}
// Forward model download progress to renderer
if (msg.status === "downloading_model" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward model downloaded status
if (msg.status === "model_downloaded" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward model loading status
if (msg.status === "loading_model" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward model loaded status
if (msg.status === "model_loaded" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward model cached status (model was already downloaded)
if (msg.status === "model_cached" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward incomplete cache status
if (msg.status === "incomplete_cache" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward model error status and mark sidecar as not ready
if (msg.status === "model_error") {
sidecarReady = false;
if (activeWhisperConfig) {
activeWhisperConfig.error = msg.error || "Model load failed";
}
if (mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
}
} catch (e) {
console.log("Sidecar output:", line);
}
@@ -201,10 +473,109 @@ function startSidecar() {
}
}
app.whenReady().then(() => {
app.whenReady().then(async () => {
// Load configuration first
loadConfig();
const backendConfig = appConfig?.backend || {};
const uiConfig = appConfig?.ui || {};
const launchBrowser = uiConfig.launchBrowser === true;
console.log("=== Startup Mode Check ===");
console.log("uiConfig:", JSON.stringify(uiConfig));
console.log("launchBrowser:", launchBrowser);
console.log("backendConfig.embedded:", backendConfig.embedded);
console.log("Will use browser mode:", launchBrowser && backendConfig.embedded);
// Browser-only mode: start backend and open browser, no Electron UI
if (launchBrowser && backendConfig.embedded) {
console.log("=== Browser-Only Mode ===");
// Set BROWSER_MODE so backend manages sidecar
process.env.BROWSER_MODE = "true";
// Start backend sidecar
startBackendSidecar();
// Wait for backend to be ready
const ready = await waitForBackendReady();
if (!ready) {
const { dialog } = require("electron");
dialog.showErrorBox(
"Backend Startup Failed",
"後端服務啟動失敗。請檢查日誌以獲取詳細信息。"
);
app.quit();
return;
}
// Open browser to login page
const host = backendConfig.host || "127.0.0.1";
const port = backendConfig.port || 8000;
const loginUrl = `http://${host}:${port}/login`;
console.log(`Opening browser: ${loginUrl}`);
await shell.openExternal(loginUrl);
// Keep app running in background
// On macOS, we need to handle dock visibility
if (process.platform === "darwin") {
app.dock.hide();
}
console.log("Backend running. Close this window or press Ctrl+C to stop.");
return;
}
// Standard Electron mode
// Grant microphone permission automatically
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
console.log(`Permission request: ${permission}`, details);
// Allow all media-related permissions
const allowedPermissions = ['media', 'mediaKeySystem', 'audioCapture', 'microphone'];
if (allowedPermissions.includes(permission)) {
console.log(`Granting permission: ${permission}`);
callback(true);
} else {
console.log(`Denying permission: ${permission}`);
callback(false);
}
});
// Also handle permission check (for some Electron versions)
session.defaultSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
console.log(`Permission check: ${permission}`, { requestingOrigin, details });
const allowedPermissions = ['media', 'mediaKeySystem', 'audioCapture', 'microphone'];
return allowedPermissions.includes(permission);
});
// Set device permission handler for media devices
session.defaultSession.setDevicePermissionHandler((details) => {
console.log('Device permission request:', details);
// Allow all audio devices
if (details.deviceType === 'audio' || details.deviceType === 'hid') {
return true;
}
return false;
});
// Start backend sidecar if embedded mode is enabled
startBackendSidecar();
// Wait for backend to be ready before creating window
if (backendConfig.embedded) {
const ready = await waitForBackendReady();
if (!ready) {
const { dialog } = require("electron");
dialog.showErrorBox(
"Backend Startup Failed",
"The backend server failed to start within 30 seconds. Please check the logs for details."
);
app.quit();
return;
}
}
createWindow();
startSidecar();
@@ -216,12 +587,17 @@ app.whenReady().then(() => {
});
app.on("window-all-closed", () => {
// Stop transcriber sidecar
if (sidecarProcess) {
try {
sidecarProcess.stdin.write(JSON.stringify({ action: "quit" }) + "\n");
} catch (e) {}
sidecarProcess.kill();
}
// Stop backend sidecar
stopBackendSidecar();
if (process.platform !== "darwin") {
app.quit();
}
@@ -239,7 +615,24 @@ ipcMain.handle("navigate", (event, page) => {
});
ipcMain.handle("get-sidecar-status", () => {
return { ready: sidecarReady, streaming: streamingActive };
return {
ready: sidecarReady,
streaming: streamingActive,
whisper: activeWhisperConfig
};
});
// Get backend status (for renderer to check backend readiness)
ipcMain.handle("get-backend-status", () => {
const backendConfig = appConfig?.backend || {};
const host = backendConfig.host || "127.0.0.1";
const port = backendConfig.port || 8000;
return {
ready: backendReady,
embedded: backendConfig.embedded || false,
url: `http://${host}:${port}`
};
});
// === Streaming Mode IPC Handlers ===
@@ -381,3 +774,42 @@ ipcMain.handle("transcribe-audio", async (event, audioFilePath) => {
}, 60000);
});
});
// === Browser Mode Handler ===
// Opens the current page in the system's default browser
// This is useful when Electron's audio access is blocked by security software
ipcMain.handle("open-in-browser", async () => {
const backendConfig = appConfig?.backend || {};
const host = backendConfig.host || "127.0.0.1";
const port = backendConfig.port || 8000;
// Determine the current page URL and preserve query parameters
let currentPage = "login";
let queryString = "";
if (mainWindow) {
const currentUrl = mainWindow.webContents.getURL();
// Parse query string from current URL (e.g., ?id=123)
const urlMatch = currentUrl.match(/\?(.+)$/);
if (urlMatch) {
queryString = "?" + urlMatch[1];
}
if (currentUrl.includes("meetings.html")) {
currentPage = "meetings";
} else if (currentUrl.includes("meeting-detail.html")) {
currentPage = "meeting-detail";
}
}
const browserUrl = `http://${host}:${port}/${currentPage}${queryString}`;
try {
await shell.openExternal(browserUrl);
return { success: true, url: browserUrl };
} catch (error) {
return { error: error.message };
}
});

View File

@@ -26,6 +26,8 @@
</div>
<script type="module">
// Browser mode polyfill (must be first)
import '../services/browser-api.js';
import { initApp } from '../services/init.js';
import { login } from '../services/api.js';

View File

@@ -139,6 +139,201 @@
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
/* Audio Device Settings Panel */
.audio-device-panel {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 15px;
overflow: hidden;
}
.audio-device-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
background: #e9ecef;
cursor: pointer;
user-select: none;
}
.audio-device-header:hover {
background: #dee2e6;
}
.audio-device-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #495057;
}
.audio-device-toggle {
font-size: 12px;
color: #6c757d;
transition: transform 0.2s;
}
.audio-device-panel.collapsed .audio-device-toggle {
transform: rotate(-90deg);
}
.audio-device-panel.collapsed .audio-device-body {
display: none;
}
.audio-device-body {
padding: 15px;
}
.audio-device-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.audio-device-row:last-child {
margin-bottom: 0;
}
.audio-device-label {
font-size: 13px;
color: #495057;
min-width: 70px;
}
.audio-device-select {
flex: 1;
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 13px;
background: white;
}
.audio-device-select:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.audio-refresh-btn {
padding: 8px;
border: 1px solid #ced4da;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 14px;
}
.audio-refresh-btn:hover {
background: #e9ecef;
}
/* Volume Meter */
.volume-meter-container {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
}
.volume-meter {
flex: 1;
height: 20px;
background: #e9ecef;
border-radius: 10px;
overflow: hidden;
position: relative;
}
.volume-meter-fill {
height: 100%;
width: 0%;
background: linear-gradient(to right, #28a745, #ffc107, #dc3545);
transition: width 0.05s ease-out;
border-radius: 10px;
}
.volume-meter-text {
font-size: 12px;
color: #6c757d;
min-width: 40px;
text-align: right;
}
/* Test Controls */
.audio-test-controls {
display: flex;
align-items: center;
gap: 10px;
}
.audio-test-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.audio-test-btn.record {
background: #dc3545;
color: white;
}
.audio-test-btn.record:hover:not(:disabled) {
background: #c82333;
}
.audio-test-btn.record.recording {
background: #6c757d;
}
.audio-test-btn.play {
background: #28a745;
color: white;
}
.audio-test-btn.play:hover:not(:disabled) {
background: #218838;
}
.audio-test-btn.play.playing {
background: #6c757d;
}
.audio-test-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.audio-status {
font-size: 12px;
color: #6c757d;
margin-left: auto;
}
.audio-status.success {
color: #28a745;
}
.audio-status.error {
color: #dc3545;
}
.audio-status.recording {
color: #dc3545;
}
.no-input-hint {
font-size: 11px;
color: #dc3545;
margin-top: 4px;
}
/* Browser Mode Hint */
.browser-mode-hint {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 6px;
margin-top: 10px;
font-size: 12px;
color: #856404;
}
.browser-mode-hint.hidden {
display: none;
}
.browser-mode-btn {
padding: 6px 12px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.browser-mode-btn:hover {
background: #0056b3;
}
</style>
</head>
<body>
@@ -164,12 +359,59 @@
</div>
</div>
<!-- Audio Device Settings Panel -->
<div id="audio-device-panel" class="audio-device-panel">
<div class="audio-device-header" id="audio-device-header">
<h3>音訊設備設定</h3>
<span class="audio-device-toggle"></span>
</div>
<div class="audio-device-body">
<!-- Device Selection Row -->
<div class="audio-device-row">
<span class="audio-device-label">麥克風:</span>
<select id="audio-device-select" class="audio-device-select">
<option value="">載入中...</option>
</select>
<button id="audio-refresh-btn" class="audio-refresh-btn" title="重新整理設備清單">🔄</button>
</div>
<!-- Volume Meter Row -->
<div class="audio-device-row">
<span class="audio-device-label">輸入音量:</span>
<div class="volume-meter-container">
<div class="volume-meter">
<div id="volume-meter-fill" class="volume-meter-fill"></div>
</div>
<span id="volume-meter-text" class="volume-meter-text">0%</span>
</div>
</div>
<!-- Test Controls Row -->
<div class="audio-device-row">
<span class="audio-device-label">收音測試:</span>
<div class="audio-test-controls">
<button id="test-record-btn" class="audio-test-btn record" title="錄製 5 秒測試音訊">
🎤 測試錄音
</button>
<button id="test-play-btn" class="audio-test-btn play" disabled title="播放測試錄音">
▶️ 播放測試
</button>
<span id="audio-status" class="audio-status">準備就緒</span>
</div>
</div>
<!-- Browser Mode Hint (shown when audio access fails) -->
<div id="browser-mode-hint" class="browser-mode-hint hidden">
<span>無法存取麥克風?安全軟體可能阻擋了 Electron。請嘗試在瀏覽器中開啟。</span>
<button id="open-browser-btn" class="browser-mode-btn">在瀏覽器中開啟</button>
</div>
</div>
</div>
<!-- Dual Panel Layout -->
<div class="dual-panel">
<!-- Left Panel: Transcript -->
<div class="panel">
<div class="panel-header">
<span>Transcript (逐字稿)</span>
<span id="whisper-status" style="font-size: 11px; color: #666; margin-left: 10px;" title="Whisper Model Info">Loading...</span>
<div class="recording-controls" style="padding: 0; display: flex; gap: 8px;">
<button class="btn btn-danger" id="record-btn">Start Recording</button>
<button class="btn btn-secondary" id="upload-audio-btn">Upload Audio</button>
@@ -235,6 +477,8 @@
</div>
<script type="module">
// Browser mode polyfill (must be first)
import '../services/browser-api.js';
import { initApp } from '../services/init.js';
import {
getMeeting,
@@ -281,6 +525,575 @@
const uploadProgressEl = document.getElementById('upload-progress');
const uploadProgressText = document.getElementById('upload-progress-text');
const uploadProgressFill = document.getElementById('upload-progress-fill');
const whisperStatusEl = document.getElementById('whisper-status');
// Audio Device Settings Elements
const audioDevicePanel = document.getElementById('audio-device-panel');
const audioDeviceHeader = document.getElementById('audio-device-header');
const audioDeviceSelect = document.getElementById('audio-device-select');
const audioRefreshBtn = document.getElementById('audio-refresh-btn');
const volumeMeterFill = document.getElementById('volume-meter-fill');
const volumeMeterText = document.getElementById('volume-meter-text');
const testRecordBtn = document.getElementById('test-record-btn');
const testPlayBtn = document.getElementById('test-play-btn');
const audioStatusEl = document.getElementById('audio-status');
const browserModeHint = document.getElementById('browser-mode-hint');
const openBrowserBtn = document.getElementById('open-browser-btn');
// Audio Device State
const audioDeviceState = {
availableDevices: [],
selectedDeviceId: null,
isMonitoring: false,
monitoringStream: null,
monitoringContext: null,
monitoringAnalyser: null,
animationFrameId: null,
testRecordingBlob: null,
testState: 'idle', // 'idle' | 'recording' | 'playing'
testMediaRecorder: null,
testAudioElement: null,
testCountdown: 0
};
// ========================================
// Audio Device Management Functions
// ========================================
// Check if deviceId is an alias (not a real device ID)
function isAliasDeviceId(id) {
return id === 'default' || id === 'communications' || !id;
}
// Enumerate audio devices and populate dropdown
async function enumerateAudioDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(d => d.kind === 'audioinput');
// Filter out virtual devices like Stereo Mix
const realDevices = audioInputs.filter(d =>
!d.label.includes('立體聲混音') &&
!d.label.toLowerCase().includes('stereo mix')
);
audioDeviceState.availableDevices = realDevices;
// Populate dropdown
audioDeviceSelect.innerHTML = '';
if (realDevices.length === 0) {
audioDeviceSelect.innerHTML = '<option value="">未偵測到麥克風</option>';
setAudioStatus('未偵測到麥克風', 'error');
return;
}
realDevices.forEach((device, index) => {
const option = document.createElement('option');
option.value = device.deviceId;
// Create friendly label
let label = device.label || `麥克風 ${index + 1}`;
if (device.deviceId === 'default') {
label = `🔹 ${label} (系統預設)`;
} else if (device.deviceId === 'communications') {
label = `📞 ${label} (通訊裝置)`;
}
option.textContent = label;
audioDeviceSelect.appendChild(option);
});
// Try to restore saved preference
const savedDeviceId = localStorage.getItem('audioDevice.selectedId');
const savedDevice = realDevices.find(d => d.deviceId === savedDeviceId);
if (savedDevice) {
audioDeviceSelect.value = savedDeviceId;
audioDeviceState.selectedDeviceId = savedDeviceId;
} else {
// Prefer non-alias device
const preferredDevice = realDevices.find(d => !isAliasDeviceId(d.deviceId)) || realDevices[0];
if (preferredDevice) {
audioDeviceSelect.value = preferredDevice.deviceId;
audioDeviceState.selectedDeviceId = preferredDevice.deviceId;
}
}
console.log('Audio devices enumerated:', realDevices.length, realDevices);
setAudioStatus('準備就緒', 'success');
// Start volume monitoring with selected device
await startVolumeMonitoring();
} catch (error) {
console.error('Failed to enumerate audio devices:', error);
audioDeviceSelect.innerHTML = '<option value="">無法存取麥克風</option>';
setAudioStatus('無法存取麥克風: ' + error.message, 'error');
}
}
// Select audio device
async function selectAudioDevice(deviceId) {
audioDeviceState.selectedDeviceId = deviceId;
// Save preference
if (deviceId) {
localStorage.setItem('audioDevice.selectedId', deviceId);
const device = audioDeviceState.availableDevices.find(d => d.deviceId === deviceId);
if (device) {
localStorage.setItem('audioDevice.lastUsedLabel', device.label);
}
}
// Restart volume monitoring with new device
await startVolumeMonitoring();
console.log('Selected audio device:', deviceId);
}
// Start volume monitoring
async function startVolumeMonitoring() {
// Stop existing monitoring
stopVolumeMonitoring();
const deviceId = audioDeviceState.selectedDeviceId;
if (!deviceId && audioDeviceState.availableDevices.length === 0) {
return;
}
try {
// Get audio stream
let constraints;
if (isAliasDeviceId(deviceId)) {
constraints = { audio: true };
} else {
constraints = { audio: { deviceId: { exact: deviceId } } };
}
const stream = await navigator.mediaDevices.getUserMedia(constraints);
audioDeviceState.monitoringStream = stream;
// Create audio context and analyser
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.3;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
audioDeviceState.monitoringContext = audioContext;
audioDeviceState.monitoringAnalyser = analyser;
audioDeviceState.isMonitoring = true;
// Start animation loop for volume meter
updateVolumeMeter();
setAudioStatus('正在監聽...', 'success');
} catch (error) {
console.error('Failed to start volume monitoring:', error);
if (error.name === 'NotAllowedError') {
setAudioStatus('麥克風權限被拒絕', 'error');
} else if (error.name === 'NotReadableError') {
setAudioStatus('麥克風被其他應用程式佔用', 'error');
} else {
setAudioStatus('無法存取麥克風', 'error');
}
// Show browser mode hint when audio access fails (only in Electron)
if (window.electronAPI && window.electronAPI.openInBrowser) {
browserModeHint.classList.remove('hidden');
}
}
}
// Stop volume monitoring
function stopVolumeMonitoring() {
if (audioDeviceState.animationFrameId) {
cancelAnimationFrame(audioDeviceState.animationFrameId);
audioDeviceState.animationFrameId = null;
}
if (audioDeviceState.monitoringStream) {
audioDeviceState.monitoringStream.getTracks().forEach(track => track.stop());
audioDeviceState.monitoringStream = null;
}
if (audioDeviceState.monitoringContext) {
audioDeviceState.monitoringContext.close();
audioDeviceState.monitoringContext = null;
}
audioDeviceState.monitoringAnalyser = null;
audioDeviceState.isMonitoring = false;
volumeMeterFill.style.width = '0%';
volumeMeterText.textContent = '0%';
}
// Update volume meter (animation loop)
function updateVolumeMeter() {
if (!audioDeviceState.isMonitoring || !audioDeviceState.monitoringAnalyser) {
return;
}
const analyser = audioDeviceState.monitoringAnalyser;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(dataArray);
// Calculate RMS (root mean square) for more accurate volume
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += dataArray[i] * dataArray[i];
}
const rms = Math.sqrt(sum / dataArray.length);
// Normalize to 0-100 range
const level = Math.min(100, Math.round((rms / 128) * 100));
volumeMeterFill.style.width = level + '%';
volumeMeterText.textContent = level + '%';
// Continue animation loop
audioDeviceState.animationFrameId = requestAnimationFrame(updateVolumeMeter);
}
// Set audio status message
function setAudioStatus(message, type = '') {
audioStatusEl.textContent = message;
audioStatusEl.className = 'audio-status';
if (type) {
audioStatusEl.classList.add(type);
}
}
// Start test recording (5 seconds)
async function startTestRecording() {
if (audioDeviceState.testState !== 'idle') return;
const deviceId = audioDeviceState.selectedDeviceId;
try {
// Get audio stream
let constraints;
if (isAliasDeviceId(deviceId)) {
constraints = { audio: true };
} else {
constraints = { audio: { deviceId: { exact: deviceId } } };
}
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// Create MediaRecorder
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
});
const chunks = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunks.push(e.data);
}
};
mediaRecorder.onstop = () => {
// Create blob from chunks
audioDeviceState.testRecordingBlob = new Blob(chunks, { type: 'audio/webm' });
stream.getTracks().forEach(track => track.stop());
// Update UI
audioDeviceState.testState = 'idle';
testRecordBtn.textContent = '🎤 測試錄音';
testRecordBtn.classList.remove('recording');
testRecordBtn.disabled = false;
testPlayBtn.disabled = false;
setAudioStatus('錄音完成,可播放測試', 'success');
// Restart volume monitoring
startVolumeMonitoring();
};
// Stop volume monitoring during test recording
stopVolumeMonitoring();
// Start recording
audioDeviceState.testMediaRecorder = mediaRecorder;
audioDeviceState.testState = 'recording';
audioDeviceState.testCountdown = 5;
mediaRecorder.start(100);
// Update UI
testRecordBtn.classList.add('recording');
testPlayBtn.disabled = true;
updateTestRecordingCountdown();
// Auto-stop after 5 seconds
setTimeout(() => {
if (audioDeviceState.testState === 'recording') {
stopTestRecording();
}
}, 5000);
} catch (error) {
console.error('Failed to start test recording:', error);
if (error.name === 'NotAllowedError') {
setAudioStatus('麥克風權限被拒絕', 'error');
} else if (error.name === 'NotReadableError') {
setAudioStatus('麥克風被其他應用程式佔用', 'error');
} else {
setAudioStatus('無法開始錄音: ' + error.message, 'error');
}
audioDeviceState.testState = 'idle';
}
}
// Update countdown during test recording
function updateTestRecordingCountdown() {
if (audioDeviceState.testState !== 'recording') return;
testRecordBtn.textContent = `⏹️ 錄音中... ${audioDeviceState.testCountdown}s`;
setAudioStatus(`錄音中... ${audioDeviceState.testCountdown}`, 'recording');
if (audioDeviceState.testCountdown > 0) {
audioDeviceState.testCountdown--;
setTimeout(updateTestRecordingCountdown, 1000);
}
}
// Stop test recording
function stopTestRecording() {
if (audioDeviceState.testState !== 'recording') return;
if (audioDeviceState.testMediaRecorder && audioDeviceState.testMediaRecorder.state !== 'inactive') {
audioDeviceState.testMediaRecorder.stop();
}
}
// Play test recording
function playTestRecording() {
if (!audioDeviceState.testRecordingBlob || audioDeviceState.testState !== 'idle') return;
// Create audio element
const blobUrl = URL.createObjectURL(audioDeviceState.testRecordingBlob);
const audio = new Audio(blobUrl);
audioDeviceState.testAudioElement = audio;
audio.onplay = () => {
audioDeviceState.testState = 'playing';
testPlayBtn.textContent = '⏹️ 停止播放';
testPlayBtn.classList.add('playing');
testRecordBtn.disabled = true;
setAudioStatus('播放中...', 'success');
};
audio.onended = () => {
audioDeviceState.testState = 'idle';
testPlayBtn.textContent = '▶️ 播放測試';
testPlayBtn.classList.remove('playing');
testRecordBtn.disabled = false;
setAudioStatus('播放完成', 'success');
URL.revokeObjectURL(blobUrl);
audioDeviceState.testAudioElement = null;
};
audio.onerror = () => {
audioDeviceState.testState = 'idle';
testPlayBtn.textContent = '▶️ 播放測試';
testPlayBtn.classList.remove('playing');
testRecordBtn.disabled = false;
setAudioStatus('播放失敗', 'error');
URL.revokeObjectURL(blobUrl);
audioDeviceState.testAudioElement = null;
};
audio.play();
}
// Stop test playback
function stopTestPlayback() {
if (audioDeviceState.testAudioElement) {
audioDeviceState.testAudioElement.pause();
audioDeviceState.testAudioElement.currentTime = 0;
// Trigger onended manually
audioDeviceState.testState = 'idle';
testPlayBtn.textContent = '▶️ 播放測試';
testPlayBtn.classList.remove('playing');
testRecordBtn.disabled = false;
setAudioStatus('準備就緒', 'success');
if (audioDeviceState.testAudioElement.src) {
URL.revokeObjectURL(audioDeviceState.testAudioElement.src);
}
audioDeviceState.testAudioElement = null;
}
}
// Toggle panel collapse
function toggleAudioDevicePanel() {
audioDevicePanel.classList.toggle('collapsed');
const isCollapsed = audioDevicePanel.classList.contains('collapsed');
localStorage.setItem('audioDevice.panelCollapsed', isCollapsed);
if (isCollapsed) {
stopVolumeMonitoring();
} else {
startVolumeMonitoring();
}
}
// Initialize audio device panel
async function initAudioDevicePanel() {
// Restore panel collapse state
const isCollapsed = localStorage.getItem('audioDevice.panelCollapsed') === 'true';
if (isCollapsed) {
audioDevicePanel.classList.add('collapsed');
}
// Event listeners
audioDeviceHeader.addEventListener('click', toggleAudioDevicePanel);
audioDeviceSelect.addEventListener('change', (e) => {
selectAudioDevice(e.target.value);
});
audioRefreshBtn.addEventListener('click', async () => {
setAudioStatus('重新整理中...', '');
await enumerateAudioDevices();
});
testRecordBtn.addEventListener('click', () => {
if (audioDeviceState.testState === 'idle') {
startTestRecording();
} else if (audioDeviceState.testState === 'recording') {
stopTestRecording();
}
});
testPlayBtn.addEventListener('click', () => {
if (audioDeviceState.testState === 'idle') {
playTestRecording();
} else if (audioDeviceState.testState === 'playing') {
stopTestPlayback();
}
});
// Browser mode button - opens in system browser when audio is blocked
if (openBrowserBtn && window.electronAPI && window.electronAPI.openInBrowser) {
openBrowserBtn.addEventListener('click', async () => {
try {
openBrowserBtn.disabled = true;
openBrowserBtn.textContent = '開啟中...';
const result = await window.electronAPI.openInBrowser();
if (result.error) {
console.error('Failed to open browser:', result.error);
openBrowserBtn.textContent = '開啟失敗';
} else {
openBrowserBtn.textContent = '已開啟';
}
setTimeout(() => {
openBrowserBtn.disabled = false;
openBrowserBtn.textContent = '在瀏覽器中開啟';
}, 2000);
} catch (error) {
console.error('Error opening browser:', error);
openBrowserBtn.disabled = false;
openBrowserBtn.textContent = '在瀏覽器中開啟';
}
});
}
// Listen for device changes (hot-plug)
navigator.mediaDevices.addEventListener('devicechange', () => {
console.log('Audio devices changed');
enumerateAudioDevices();
});
// Initial enumeration (only if panel is not collapsed)
if (!isCollapsed) {
await enumerateAudioDevices();
}
}
// Get selected device for main recording
function getSelectedAudioDevice() {
return audioDeviceState.selectedDeviceId;
}
// Initialize audio device panel on page load
initAudioDevicePanel();
// ========================================
// End Audio Device Management
// ========================================
// Update Whisper status display
async function updateWhisperStatus() {
try {
const status = await window.electronAPI.getSidecarStatus();
if (status.whisper) {
// Check if there was an error loading the model
if (status.whisper.error) {
whisperStatusEl.textContent = `❌ Model error: ${status.whisper.error}`;
whisperStatusEl.style.color = '#dc3545';
whisperStatusEl.title = 'Model failed to load';
} else {
const readyIcon = status.ready ? '✅' : '⏳';
whisperStatusEl.textContent = `${readyIcon} Model: ${status.whisper.model} | Device: ${status.whisper.device} | Compute: ${status.whisper.compute}`;
whisperStatusEl.title = `Config source: ${status.whisper.configSource || 'unknown'}`;
whisperStatusEl.style.color = status.ready ? '#28a745' : '#ffc107';
}
} else {
whisperStatusEl.textContent = status.ready ? '✅ Ready' : '⏳ Loading...';
whisperStatusEl.style.color = status.ready ? '#28a745' : '#ffc107';
}
} catch (error) {
whisperStatusEl.textContent = '❌ Error';
whisperStatusEl.style.color = '#dc3545';
console.error('Failed to get sidecar status:', error);
}
}
// Initial status check and periodic updates
updateWhisperStatus();
const whisperStatusInterval = setInterval(updateWhisperStatus, 5000);
// Listen for model download progress events
window.electronAPI.onModelDownloadProgress((progress) => {
console.log('Model download progress:', progress);
if (progress.status === 'downloading_model') {
const percent = progress.progress || 0;
const downloadedMb = progress.downloaded_mb || 0;
const totalMb = progress.total_mb || 0;
whisperStatusEl.textContent = `⬇️ Downloading ${progress.model}: ${percent}% (${downloadedMb}/${totalMb} MB)`;
whisperStatusEl.style.color = '#ff9800';
} else if (progress.status === 'model_downloaded') {
whisperStatusEl.textContent = `${progress.model} downloaded, loading...`;
whisperStatusEl.style.color = '#28a745';
} else if (progress.status === 'model_cached') {
whisperStatusEl.textContent = `${progress.model} cached, loading...`;
whisperStatusEl.style.color = '#28a745';
} else if (progress.status === 'incomplete_cache') {
whisperStatusEl.textContent = `⚠️ ${progress.model} cache incomplete, re-downloading...`;
whisperStatusEl.style.color = '#ff9800';
} else if (progress.status === 'loading_model') {
whisperStatusEl.textContent = `⏳ Loading ${progress.model}...`;
whisperStatusEl.style.color = '#ffc107';
} else if (progress.status === 'model_loaded') {
whisperStatusEl.textContent = `✅ Model ready`;
whisperStatusEl.style.color = '#28a745';
// Trigger a status refresh
updateWhisperStatus();
} else if (progress.status === 'model_error') {
whisperStatusEl.textContent = `❌ Error: ${progress.error || 'Model load failed'}`;
whisperStatusEl.style.color = '#dc3545';
}
});
// Load meeting data
async function loadMeeting() {
@@ -355,9 +1168,47 @@
return;
}
// Stop volume monitoring during main recording
stopVolumeMonitoring();
// Get selected device from audio device panel
const selectedDeviceId = getSelectedAudioDevice();
console.log('Using selected audio device:', selectedDeviceId);
// Get microphone stream with user-selected device
try {
let constraints;
if (isAliasDeviceId(selectedDeviceId)) {
// For alias deviceIds (default/communications), let the system choose
console.log('Using system default (alias detected)');
constraints = { audio: true };
} else if (selectedDeviceId) {
// For real deviceIds, try exact first, then ideal as fallback
constraints = { audio: { deviceId: { exact: selectedDeviceId } } };
} else {
// No device selected, use default
constraints = { audio: true };
}
try {
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (exactErr) {
if (selectedDeviceId && !isAliasDeviceId(selectedDeviceId)) {
console.warn('Exact device ID failed, trying ideal:', exactErr);
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
audio: { deviceId: { ideal: selectedDeviceId } }
});
} else {
throw exactErr;
}
}
console.log('Successfully connected to microphone');
} catch (err) {
console.error('getUserMedia failed:', err.name, err.message);
// Restart volume monitoring on error
startVolumeMonitoring();
throw err; // Let outer catch handle the error message
}
isRecording = true;
recordBtn.textContent = 'Stop Recording';
@@ -372,7 +1223,15 @@
} catch (error) {
console.error('Start recording error:', error);
alert('Error starting recording: ' + error.message);
let errorMsg = '無法開始錄音: ' + error.message;
if (error.name === 'NotAllowedError') {
errorMsg = '麥克風權限被拒絕,請在系統設定中允許存取麥克風。';
} else if (error.name === 'NotFoundError') {
errorMsg = '未偵測到麥克風,請連接麥克風後重試。';
} else if (error.name === 'NotReadableError') {
errorMsg = '麥克風正被其他應用程式使用,請關閉其他使用麥克風的程式後重試。';
}
alert(errorMsg);
await cleanupRecording();
}
}
@@ -505,6 +1364,11 @@
recordBtn.classList.add('btn-danger');
streamingStatusEl.classList.add('hidden');
processingIndicatorEl.classList.add('hidden');
// Restart volume monitoring after recording ends
if (!audioDevicePanel.classList.contains('collapsed')) {
startVolumeMonitoring();
}
}
// === Audio File Upload ===

View File

@@ -67,6 +67,8 @@
</div>
<script type="module">
// Browser mode polyfill (must be first)
import '../services/browser-api.js';
import { initApp } from '../services/init.js';
import { getMeetings, createMeeting, clearToken } from '../services/api.js';

View File

@@ -7,9 +7,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Navigation
navigate: (page) => ipcRenderer.invoke("navigate", page),
// Sidecar status
// Sidecar status (Whisper transcriber)
getSidecarStatus: () => ipcRenderer.invoke("get-sidecar-status"),
// Backend status (FastAPI server)
getBackendStatus: () => ipcRenderer.invoke("get-backend-status"),
// === Streaming Mode APIs ===
startRecordingStream: () => ipcRenderer.invoke("start-recording-stream"),
streamAudioChunk: (base64Audio) => ipcRenderer.invoke("stream-audio-chunk", base64Audio),
@@ -26,10 +29,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("stream-stopped", (event, data) => callback(data));
},
// Model download progress events
onModelDownloadProgress: (callback) => {
ipcRenderer.on("model-download-progress", (event, progress) => callback(progress));
},
// === 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));
},
// === Browser Mode ===
// Open current page in system browser (useful when Electron audio is blocked)
openInBrowser: () => ipcRenderer.invoke("open-in-browser"),
});

View File

@@ -0,0 +1,339 @@
/**
* Browser API Implementation
*
* Provides a compatible interface for pages that normally use electronAPI
* when running in browser mode. Uses HTTP API to communicate with the
* backend sidecar for transcription functionality.
*/
// Check if we're running in Electron or browser
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
// Base URL for API calls (relative in browser mode)
const API_BASE = '';
// Progress listeners
const progressListeners = [];
const segmentListeners = [];
const streamStopListeners = [];
// WebSocket for streaming
let streamingSocket = null;
// Browser mode API implementation
const browserAPI = {
// Get app configuration (browser mode fetches from backend or uses defaults)
getConfig: async () => {
try {
// Try to fetch config from backend
const response = await fetch(`${API_BASE}/config/settings.js`);
if (response.ok) {
// settings.js exports a config object, parse it
const text = await response.text();
// Simple extraction of the config object
const match = text.match(/export\s+const\s+config\s*=\s*(\{[\s\S]*?\});/);
if (match) {
// Use eval cautiously here - it's our own config file
const configStr = match[1];
return eval('(' + configStr + ')');
}
}
} catch (error) {
console.log('[Browser Mode] Could not load config from server, using defaults');
}
// Return browser mode defaults
return {
apiBaseUrl: `${window.location.origin}/api`,
uploadTimeout: 600000,
appTitle: "Meeting Assistant",
whisper: {
model: "medium",
device: "cpu",
compute: "int8"
}
};
},
// Navigate to a page
navigate: (page) => {
const pageMap = {
'login': '/login',
'meetings': '/meetings',
'meeting-detail': '/meeting-detail'
};
window.location.href = pageMap[page] || `/${page}`;
},
// Get sidecar status
getSidecarStatus: async () => {
try {
const response = await fetch(`${API_BASE}/api/sidecar/status`);
if (response.ok) {
return await response.json();
}
return {
ready: false,
streaming: false,
whisper: null,
browserMode: true,
message: '無法取得轉寫引擎狀態'
};
} catch (error) {
console.error('[Browser Mode] getSidecarStatus error:', error);
return {
ready: false,
streaming: false,
whisper: null,
browserMode: true,
available: false,
message: '無法連接到後端服務'
};
}
},
// Model download progress listener
onModelDownloadProgress: (callback) => {
progressListeners.push(callback);
// Start polling for status updates
if (progressListeners.length === 1) {
startProgressPolling();
}
},
// Save audio file and return path (for browser mode, we handle differently)
saveAudioFile: async (arrayBuffer) => {
// In browser mode, we don't save to file system
// Instead, we'll convert to base64 and return it
// The transcribeAudio function will handle the base64 data
const base64 = arrayBufferToBase64(arrayBuffer);
return `base64:${base64}`;
},
// Transcribe audio
transcribeAudio: async (filePath) => {
try {
let response;
if (filePath.startsWith('base64:')) {
// Handle base64 encoded audio from saveAudioFile
const base64Data = filePath.substring(7);
response = await fetch(`${API_BASE}/api/sidecar/transcribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audio_data: base64Data })
});
} else {
// Handle actual file path (shouldn't happen in browser mode)
throw new Error('File path transcription not supported in browser mode');
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Transcription failed');
}
return await response.json();
} catch (error) {
console.error('[Browser Mode] transcribeAudio error:', error);
throw error;
}
},
// Transcription segment listener (for streaming mode)
onTranscriptionSegment: (callback) => {
segmentListeners.push(callback);
},
// Stream stopped listener
onStreamStopped: (callback) => {
streamStopListeners.push(callback);
},
// Start recording stream (WebSocket-based)
startRecordingStream: async () => {
try {
// Use HTTP endpoint for starting stream
const response = await fetch(`${API_BASE}/api/sidecar/stream/start`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
return { error: error.detail || 'Failed to start stream' };
}
const result = await response.json();
if (result.status === 'streaming') {
return { status: 'streaming', session_id: result.session_id };
}
return result;
} catch (error) {
console.error('[Browser Mode] startRecordingStream error:', error);
return { error: error.message };
}
},
// Stream audio chunk
streamAudioChunk: async (base64Audio) => {
try {
const response = await fetch(`${API_BASE}/api/sidecar/stream/chunk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: base64Audio })
});
if (!response.ok) {
const error = await response.json();
return { error: error.detail || 'Failed to send chunk' };
}
const result = await response.json();
// If we got a segment, notify listeners
if (result.segment && result.segment.text) {
segmentListeners.forEach(cb => {
try {
cb(result.segment);
} catch (e) {
console.error('[Browser Mode] Segment listener error:', e);
}
});
}
return result;
} catch (error) {
console.error('[Browser Mode] streamAudioChunk error:', error);
return { error: error.message };
}
},
// Stop recording stream
stopRecordingStream: async () => {
try {
const response = await fetch(`${API_BASE}/api/sidecar/stream/stop`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
return { error: error.detail || 'Failed to stop stream' };
}
const result = await response.json();
// Notify stream stop listeners
streamStopListeners.forEach(cb => {
try {
cb(result);
} catch (e) {
console.error('[Browser Mode] Stream stop listener error:', e);
}
});
return result;
} catch (error) {
console.error('[Browser Mode] stopRecordingStream error:', error);
return { error: error.message };
}
},
// Get backend status
getBackendStatus: async () => {
try {
const response = await fetch('/api/health');
if (response.ok) {
return { ready: true };
}
return { ready: false };
} catch {
return { ready: false };
}
},
// Open in browser - no-op in browser mode (already in browser)
openInBrowser: async () => {
console.log('[Browser Mode] Already running in browser');
return { success: true, url: window.location.href };
},
// Legacy transcription result listener (for file-based mode)
onTranscriptionResult: (callback) => {
// Not used in browser streaming mode, but provide for compatibility
console.log('[Browser Mode] onTranscriptionResult registered (legacy)');
},
// Stream started listener
onStreamStarted: (callback) => {
// HTTP-based streaming doesn't have this event
console.log('[Browser Mode] onStreamStarted registered');
}
};
// Helper function to convert ArrayBuffer to base64
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Poll for sidecar status/progress updates
let progressPollingInterval = null;
let lastStatus = {};
function startProgressPolling() {
if (progressPollingInterval) return;
progressPollingInterval = setInterval(async () => {
try {
const response = await fetch(`${API_BASE}/api/sidecar/status`);
if (response.ok) {
const status = await response.json();
// Check for status changes to report
const currentStatus = status.status || (status.ready ? 'ready' : 'loading');
if (currentStatus !== lastStatus.status) {
// Notify progress listeners
progressListeners.forEach(cb => {
try {
cb(status);
} catch (e) {
console.error('[Browser Mode] Progress listener error:', e);
}
});
}
lastStatus = status;
// Stop polling once ready
if (status.ready) {
clearInterval(progressPollingInterval);
progressPollingInterval = null;
}
}
} catch (error) {
console.error('[Browser Mode] Progress polling error:', error);
}
}, 2000);
}
// Export the appropriate API based on environment
export const electronAPI = isElectron ? window.electronAPI : browserAPI;
// Also set it on window for pages that access it directly
if (!isElectron && typeof window !== 'undefined') {
window.electronAPI = browserAPI;
console.log('[Browser Mode] Running in browser mode with full transcription support');
console.log('[Browser Mode] 透過後端 Sidecar 提供即時語音轉寫功能');
}

View File

@@ -0,0 +1,129 @@
# Design: Extract Environment Variables
## Context
專案需要支援以下部署場景:
1. 開發環境:前後端在本地同時運行
2. 生產環境:後端部署於 1Panel 伺服器前端Electron 應用)獨立打包部署
**架構說明**
- **後端**FastAPI 服務,使用兩個 Dify 服務LLM 摘要 + STT 轉錄)
- **前端**Electron 應用,包含 Sidecar本地 Whisper 即時轉錄服務)
目前的硬編碼配置使得部署困難,且敏感資訊(如 API 密鑰、資料庫密碼)散落在代碼中。
## Goals / Non-Goals
### Goals
- 將所有硬編碼配置提取到環境變數
- 提供完整的 `.env.example` 範例檔案
- 支援前端獨立打包時指定後端 API URL
- 提供 1Panel 部署完整指南和腳本
- 確保向後相容(預設值與現有行為一致)
### Non-Goals
- 不實現配置熱重載
- 不實現密鑰輪換機制
- 不實現多環境配置管理(如 .env.production, .env.staging
## Decisions
### 1. 環境變數命名規範
**決定**:使用大寫蛇形命名法,前端變數加 `VITE_` 前綴
**原因**
- Vite 要求客戶端環境變數必須以 `VITE_` 開頭
- 大寫蛇形是環境變數的標準慣例
### 2. 前端 API URL 配置
**決定**:使用 `VITE_API_BASE_URL` 環境變數,在 `api.js` 中讀取
```javascript
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api";
```
**替代方案**
- 使用 runtime 配置檔案(如 `/config.js`- 更靈活但增加部署複雜度
- 使用相對路徑 `/api` - 需要 nginx 反向代理,不適合獨立部署
### 3. 超時配置單位
**決定**統一使用毫秒ms與 JavaScript 一致
**後端配置項**
| 變數名 | 預設值 | 用途 |
|--------|--------|------|
| UPLOAD_TIMEOUT | 600000 | 大檔案上傳10分鐘 |
| DIFY_STT_TIMEOUT | 300000 | Dify STT 轉錄每個分塊5分鐘 |
| LLM_TIMEOUT | 120000 | Dify LLM 摘要處理2分鐘 |
| AUTH_TIMEOUT | 30000 | 認證 API 調用30秒 |
**前端/Sidecar 配置項**
| 變數名 | 預設值 | 用途 |
|--------|--------|------|
| WHISPER_MODEL | medium | 本地 Whisper 模型大小 |
| WHISPER_DEVICE | cpu | 執行裝置cpu/cuda |
| WHISPER_COMPUTE | int8 | 運算精度 |
### 4. 1Panel 部署架構
**決定**:使用 systemd 管理後端服務nginx 反向代理
```
[Client] → [Nginx:443] → [Uvicorn:8000]
[Static Files]
```
**原因**
- systemd 提供進程管理、日誌、自動重啟
- nginx 處理 HTTPS、靜態檔案、反向代理
- 這是 1Panel 的標準部署模式
### 5. CORS 配置
**決定**:保持 `allow_origins=["*"]`,不額外配置
**原因**
- 前端是 Electron 桌面應用,分發到多台電腦
- Electron 主進程的 HTTP 請求不受 CORS 限制
- 簡化部署配置IT 只需關心 HOST 和 PORT
## Risks / Trade-offs
### 風險 1環境變數遺漏
- **風險**:部署時遺漏必要的環境變數導致服務異常
- **緩解**:提供完整的 `.env.example`,啟動時檢查必要變數
### 風險 2前端打包後無法修改 API URL
- **風險**Vite 環境變數在打包時固定
- **緩解**:文件中說明需要為不同環境分別打包,或考慮未來實現 runtime 配置
### 風險 3敏感資訊外洩
- **風險**`.env` 檔案被提交到版本控制
- **緩解**:確保 `.gitignore` 包含 `.env`,只提交 `.env.example`
## Migration Plan
1. **Phase 1 - 後端配置**
- 更新 `config.py` 添加新配置項
- 更新各 router 使用配置
- 更新 `.env``.env.example`
2. **Phase 2 - 前端配置**
- 創建 `.env``.env.example`
- 更新 `api.js` 使用環境變數
3. **Phase 3 - 部署文件**
- 創建 1Panel 部署指南
- 創建部署腳本
4. **Rollback**
- 所有配置都有預設值,回滾只需刪除環境變數
## Open Questions
- Q: 是否需要支援 Docker 部署?
- A: 暫不包含,但環境變數配置天然支援 Docker

View File

@@ -0,0 +1,85 @@
# Change: Extract Hardcoded Configurations to Environment Variables
## Why
專案中存在大量硬編碼的路徑、URL、API 端點、埠號及敏感資訊,這些配置散落在前後端程式碼中。為了支援獨立部署(後端部署於 1Panel 伺服器,前端獨立打包),需要將這些配置統一提取到環境變數檔案中管理,提高部署彈性與安全性。
## What Changes
### 後端配置提取
後端使用兩個 Dify 服務:
- **LLM 服務**`DIFY_API_KEY`- 產生會議結論及行動事項
- **STT 服務**`DIFY_STT_API_KEY`- 上傳音訊檔案的語音轉文字
1. **新增環境變數**
- `BACKEND_HOST` - 後端監聽地址預設0.0.0.0
- `BACKEND_PORT` - 後端監聽埠號預設8000
- `DB_POOL_SIZE` - 資料庫連線池大小預設5
- `JWT_EXPIRE_HOURS` - JWT Token 過期時間預設24
- `UPLOAD_TIMEOUT` - 檔案上傳超時時間預設600000ms
- `DIFY_STT_TIMEOUT` - Dify STT 轉錄超時時間預設300000ms
- `LLM_TIMEOUT` - Dify LLM 處理超時時間預設120000ms
- `AUTH_TIMEOUT` - 認證 API 超時時間預設30000ms
- `TEMPLATE_DIR` - Excel 範本目錄路徑
- `RECORD_DIR` - 會議記錄匯出目錄路徑
- `MAX_FILE_SIZE` - 最大上傳檔案大小預設500MB
- `SUPPORTED_AUDIO_FORMATS` - 支援的音訊格式
**註**CORS 保持 `allow_origins=["*"]`,因為前端是 Electron 桌面應用,無需細粒度控制。
2. **已存在環境變數**(確認文件化)
- `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME` - 資料庫配置
- `AUTH_API_URL` - 認證 API 端點
- `DIFY_API_URL` - Dify API 基礎 URL
- `DIFY_API_KEY` - Dify LLM 服務金鑰
- `DIFY_STT_API_KEY` - Dify STT 服務金鑰
- `ADMIN_EMAIL` - 管理員郵箱
- `JWT_SECRET` - JWT 密鑰
### 前端/Electron 配置提取
前端包含 Sidecar本地 Whisper 即時轉錄服務)。
1. **Vite 環境變數**(打包時使用)
- `VITE_API_BASE_URL` - 後端 API 基礎 URL預設http://localhost:8000/api
- `VITE_UPLOAD_TIMEOUT` - 大檔案上傳超時時間預設600000ms
- `VITE_APP_TITLE` - 應用程式標題
2. **Sidecar/Whisper 環境變數**(執行時使用)
- `WHISPER_MODEL` - 模型大小預設medium
- `WHISPER_DEVICE` - 執行裝置預設cpu
- `WHISPER_COMPUTE` - 運算精度預設int8
- `SIDECAR_DIR` - Sidecar 目錄路徑Electron 打包時使用)
### 部署文件與腳本
1. **1Panel 部署指南** - `docs/1panel-deployment.md`
2. **後端部署腳本** - `scripts/deploy-backend.sh`
3. **環境變數範例檔案**
- 更新 `backend/.env.example`
- 新增 `client/.env.example`
## Impact
- Affected specs: `middleware`
- Affected code:
- `backend/app/config.py` - 新增配置項
- `backend/app/database.py` - 使用連線池配置
- `backend/app/routers/ai.py` - 使用 Dify 超時配置
- `backend/app/routers/auth.py` - 使用認證超時配置
- `backend/app/routers/export.py` - 使用目錄路徑配置
- `client/src/services/api.js` - 使用 Vite 環境變數
- `client/src/main.js` - Sidecar 路徑配置
- `start.sh` - 更新啟動腳本
## 部署流程簡化
**IT 只需提供:**
1. 後端伺服器 IP/域名
2. 後端使用的 PORT
**開發者打包前端時:**
1. 設定 `VITE_API_BASE_URL=http://<伺服器>:<PORT>/api`
2. 執行打包命令
3. 分發 EXE 給使用者

View File

@@ -0,0 +1,122 @@
## MODIFIED Requirements
### Requirement: FastAPI Server Configuration
The middleware server SHALL be implemented using Python FastAPI framework with comprehensive environment-based configuration supporting standalone deployment.
#### Scenario: Server startup with valid configuration
- **WHEN** the server starts with valid .env file containing all required variables (DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME, DIFY_API_URL, DIFY_API_KEY, AUTH_API_URL)
- **THEN** the server SHALL start successfully and accept connections on the configured BACKEND_HOST and BACKEND_PORT
#### Scenario: Server startup with missing configuration
- **WHEN** the server starts with missing required environment variables
- **THEN** the server SHALL fail to start with descriptive error message
#### Scenario: Server startup with optional configuration
- **WHEN** optional environment variables (BACKEND_PORT, DB_POOL_SIZE, etc.) are not set
- **THEN** the server SHALL use sensible defaults and start normally
### Requirement: Database Connection Pool
The middleware server SHALL maintain a configurable connection pool to the MySQL database using environment variables.
#### Scenario: Database connection success
- **WHEN** the server connects to MySQL with valid credentials from environment
- **THEN** a connection pool SHALL be established with DB_POOL_SIZE connections
#### Scenario: Database connection failure
- **WHEN** the database is unreachable
- **THEN** the server SHALL return HTTP 503 with error details for affected endpoints
### Requirement: CORS Configuration
The middleware server SHALL allow cross-origin requests from all origins to support Electron desktop application clients.
#### Scenario: CORS preflight request
- **WHEN** any client sends OPTIONS request
- **THEN** the server SHALL respond with CORS headers allowing the request (allow_origins=["*"])
## ADDED Requirements
### Requirement: Backend Server Configuration
The middleware server SHALL support configurable host and port through environment variables for flexible deployment.
#### Scenario: Custom port binding
- **WHEN** BACKEND_PORT environment variable is set to 9000
- **THEN** the server SHALL listen on port 9000
#### Scenario: Production host binding
- **WHEN** BACKEND_HOST is set to 0.0.0.0
- **THEN** the server SHALL accept connections from any network interface
#### Scenario: Default configuration
- **WHEN** BACKEND_HOST and BACKEND_PORT are not set
- **THEN** the server SHALL default to 0.0.0.0:8000
### Requirement: Timeout Configuration
The middleware server SHALL support configurable timeout values for different operations through environment variables.
#### Scenario: File upload timeout
- **WHEN** UPLOAD_TIMEOUT is set to 900000 (15 minutes)
- **THEN** file upload operations SHALL allow up to 15 minutes before timeout
#### Scenario: LLM processing timeout
- **WHEN** LLM_TIMEOUT is set to 180000 (3 minutes)
- **THEN** Dify LLM summarization operations SHALL allow up to 3 minutes before timeout
#### Scenario: Dify STT timeout
- **WHEN** DIFY_STT_TIMEOUT is set to 600000 (10 minutes)
- **THEN** Dify STT audio transcription per chunk SHALL allow up to 10 minutes before timeout
#### Scenario: Authentication timeout
- **WHEN** AUTH_TIMEOUT is set to 60000 (1 minute)
- **THEN** authentication API calls SHALL allow up to 1 minute before timeout
### Requirement: File Path Configuration
The middleware server SHALL support configurable directory paths for templates and records.
#### Scenario: Custom template directory
- **WHEN** TEMPLATE_DIR environment variable is set to /data/templates
- **THEN** Excel templates SHALL be loaded from /data/templates
#### Scenario: Custom record directory
- **WHEN** RECORD_DIR environment variable is set to /data/records
- **THEN** exported meeting records SHALL be saved to /data/records
#### Scenario: Relative path resolution
- **WHEN** directory paths are relative
- **THEN** they SHALL be resolved relative to the backend application root
### Requirement: Frontend Environment Configuration
The frontend Electron application SHALL support environment-based API URL configuration for connecting to deployed backend.
#### Scenario: Custom API URL in production build
- **WHEN** VITE_API_BASE_URL is set to http://192.168.1.100:8000/api during build
- **THEN** the built Electron app SHALL connect to http://192.168.1.100:8000/api
#### Scenario: Default API URL in development
- **WHEN** VITE_API_BASE_URL is not set
- **THEN** the frontend SHALL default to http://localhost:8000/api
### Requirement: Sidecar Whisper Configuration
The Electron frontend's Sidecar (local Whisper transcription service) SHALL support environment-based model configuration.
#### Scenario: Custom Whisper model
- **WHEN** WHISPER_MODEL environment variable is set to "large"
- **THEN** the Sidecar SHALL load the large Whisper model for transcription
#### Scenario: GPU acceleration
- **WHEN** WHISPER_DEVICE is set to "cuda" and WHISPER_COMPUTE is set to "float16"
- **THEN** the Sidecar SHALL use GPU for faster transcription
#### Scenario: Default CPU mode
- **WHEN** WHISPER_DEVICE is not set
- **THEN** the Sidecar SHALL default to CPU with int8 compute type
### Requirement: Environment Example Files
The project SHALL provide example environment files documenting all configuration options.
#### Scenario: Backend environment example
- **WHEN** developer sets up backend
- **THEN** backend/.env.example SHALL list all environment variables with descriptions and example values (without sensitive data)
#### Scenario: Frontend environment example
- **WHEN** developer sets up frontend
- **THEN** client/.env.example SHALL list all VITE_ prefixed and WHISPER_ prefixed environment variables with descriptions

View File

@@ -0,0 +1,45 @@
# Tasks: Extract Environment Variables
## 1. Backend Configuration
- [x] 1.1 Update `backend/app/config.py` with new environment variables
- Add: BACKEND_HOST, BACKEND_PORT
- Add: DB_POOL_SIZE, JWT_EXPIRE_HOURS
- Add: UPLOAD_TIMEOUT, DIFY_STT_TIMEOUT, LLM_TIMEOUT, AUTH_TIMEOUT
- Add: TEMPLATE_DIR, RECORD_DIR, MAX_FILE_SIZE, SUPPORTED_AUDIO_FORMATS
- [x] 1.2 Update `backend/app/database.py` to use DB_POOL_SIZE from config
- [x] 1.3 Update `backend/app/routers/ai.py` to use Dify timeout configs (DIFY_STT_TIMEOUT, LLM_TIMEOUT)
- [x] 1.4 Update `backend/app/routers/auth.py` to use AUTH_TIMEOUT and JWT_EXPIRE_HOURS from config
- [x] 1.5 Update `backend/app/routers/export.py` to use TEMPLATE_DIR and RECORD_DIR from config
- [x] 1.6 Update `backend/.env` with all new variables
- [x] 1.7 Update `backend/.env.example` with all variables (without sensitive values)
## 2. Frontend/Electron Configuration
- [x] 2.1 Create `client/.env` with VITE_API_BASE_URL and Whisper settings
- [x] 2.2 Create `client/.env.example` as template
- [x] 2.3 Update `client/src/services/api.js` to use import.meta.env.VITE_API_BASE_URL
- [x] 2.4 Update `client/src/main.js` to pass Whisper env vars to Sidecar process
## 3. Startup Scripts
- [x] 3.1 Update `start.sh` to load environment variables properly
- [x] 3.2 Create `scripts/deploy-backend.sh` for standalone backend deployment
## 4. Deployment Documentation
- [x] 4.1 Create `docs/1panel-deployment.md` with step-by-step guide
- Include: Prerequisites and system requirements
- Include: Python environment setup
- Include: Environment variable configuration (IT only needs HOST + PORT)
- Include: Nginx reverse proxy configuration example
- Include: Systemd service file example
- Include: SSL/HTTPS setup guide (optional)
- Include: Troubleshooting common issues
## 5. Validation
- [ ] 5.1 Test backend starts with new config
- [ ] 5.2 Test frontend builds with environment variables
- [ ] 5.3 Test API connectivity between frontend and backend
- [ ] 5.4 Verify all hardcoded values are externalized

View File

@@ -0,0 +1,145 @@
# Design: add-audio-device-selector
## Architecture Overview
### Component Structure
```
meeting-detail.html
├── Audio Device Panel (新增)
│ ├── Device Selector (dropdown)
│ ├── Volume Meter (canvas/div bars)
│ ├── Test Controls
│ │ ├── Start Test Button
│ │ ├── Stop Test Button
│ │ └── Play Test Button
│ └── Status Indicator
└── Existing Recording Controls
└── Uses selected device from panel
```
### Data Flow
```
User selects device → Update localStorage → Update AudioContext
→ Start volume monitoring
→ Enable test recording
Test Recording Flow:
Start Test → getUserMedia(selected device) → MediaRecorder → Blob
Play Test → Audio element → Play blob URL
Main Recording Flow:
Start Recording → Read selected device from state
→ getUserMedia(selected device)
→ Existing transcription flow
```
## Technical Decisions
### TD-1: Volume Meter Implementation
**Options Considered:**
1. **Web Audio API AnalyserNode** - Real-time frequency/amplitude analysis
2. **MediaRecorder + periodic sampling** - Sample audio levels periodically
3. **CSS-only animation** - Fake animation without real audio data
**Decision:** Web Audio API AnalyserNode
- Provides accurate real-time audio level data
- Low latency visualization
- Standard browser API, well-supported in Electron
### TD-2: Device Preference Storage
**Options Considered:**
1. **localStorage** - Simple key-value storage
2. **config.json** - App configuration file
3. **Backend database** - Per-user settings
**Decision:** localStorage
- No backend changes required
- Immediate persistence
- Per-device settings (user may use different mics on different computers)
### TD-3: Test Recording Duration
**Decision:** 5 seconds fixed duration
- Long enough to verify audio quality
- Short enough to not waste time
- Auto-stop prevents forgotten recordings
### TD-4: UI Placement
**Options Considered:**
1. **Modal dialog** - Opens on demand
2. **Collapsible panel** - Always visible but can be collapsed
3. **Settings page** - Separate page for audio settings
**Decision:** Collapsible panel in meeting-detail page
- Quick access before recording
- No page navigation needed
- Can be collapsed when not needed
## UI Mockup
```
┌─────────────────────────────────────────────────────────┐
│ Audio Device Settings [▼] │
├─────────────────────────────────────────────────────────┤
│ Microphone: [▼ Realtek Microphone (Realtek Audio) ▼] │
│ │
│ Input Level: ████████░░░░░░░░░░░░ 45% │
│ │
│ [🎤 Test Recording] [▶️ Play Test] Status: Ready │
│ │
Click "Test Recording" to verify your microphone │
└─────────────────────────────────────────────────────────┘
```
## State Management
### Audio Device State
```javascript
const audioDeviceState = {
availableDevices: [], // Array of MediaDeviceInfo
selectedDeviceId: null, // Selected device ID or null for default
isMonitoring: false, // Volume meter active
currentLevel: 0, // Current audio level 0-100
testRecording: null, // Blob of test recording
testState: 'idle' // 'idle' | 'recording' | 'playing'
};
```
### localStorage Keys
- `audioDevice.selectedId` - Last selected device ID
- `audioDevice.lastUsedLabel` - Device label for display fallback
## Integration Points
### With Existing Recording
1. `startRecording()` will read `selectedDeviceId` from state
2. If no device selected, use current auto-selection logic
3. If selected device unavailable, show error and prompt reselection
### IPC Considerations
- No new IPC handlers needed
- All audio device operations happen in renderer process
- Uses existing `navigator.mediaDevices` API
## Error Handling
| Error | User Message | Recovery |
|-------|-------------|----------|
| No devices found | "未偵測到麥克風,請連接麥克風後重試" | Refresh device list |
| Device disconnected | "選擇的麥克風已斷開連接" | Auto-switch to default |
| Permission denied | "麥克風權限被拒絕,請在系統設定中允許" | Show permission guide |
| Device busy | "麥克風正被其他應用程式使用" | Retry button |
## Testing Strategy
### Manual Testing
1. Connect multiple microphones
2. Verify all appear in dropdown
3. Select each and verify volume meter responds
4. Record and play test audio for each
5. Unplug device during use and verify error handling
6. Restart app and verify saved preference loads
### Automated Testing (Future)
- Mock `navigator.mediaDevices` for unit tests
- Test device switching logic
- Test localStorage persistence

View File

@@ -0,0 +1,45 @@
# Proposal: add-audio-device-selector
## Summary
新增音訊設備選擇與驗證功能,讓使用者可以手動選擇麥克風、即時預覽音量、進行收音測試及播放測試錄音。
## Problem Statement
目前系統自動選擇麥克風,使用者無法:
1. 查看可用的音訊輸入設備清單
2. 手動選擇偏好的麥克風
3. 在錄音前確認麥克風是否正常運作
4. 測試收音品質
這導致使用者在錄音失敗時難以診斷問題,也無法在多個麥克風之間切換。
## Proposed Solution
在會議詳情頁面新增音訊設備管理面板,包含:
1. **設備選擇器**:下拉選單顯示所有可用麥克風
2. **音量指示器**即時顯示麥克風輸入音量VU meter
3. **收音測試**:錄製 5 秒測試音訊
4. **播放測試**:播放剛錄製的測試音訊
5. **設備狀態指示**:顯示目前選中設備的連線狀態
## Scope
- **In Scope**:
- 前端 UI 元件(設備選擇器、音量計、測試按鈕)
- 設備列舉與切換邏輯
- 測試錄音與播放功能
- 使用者偏好設定儲存localStorage
- **Out of Scope**:
- 系統音訊輸出設備選擇
- 音訊處理效果(降噪、增益等)
- 遠端音訊設備支援
## Success Criteria
- 使用者可以看到所有可用麥克風並選擇一個
- 選擇麥克風後可即時看到音量變化
- 測試錄音功能可錄製 5 秒音訊並播放
- 偏好設定在下次開啟時保留
- 錄音功能使用使用者選擇的麥克風
## Stakeholders
- End Users: 會議記錄人員
- Developers: 前端開發團隊

View File

@@ -0,0 +1,131 @@
# audio-device-management Specification Delta
## ADDED Requirements
### Requirement: Audio Device Enumeration
The frontend SHALL enumerate and display all available audio input devices.
#### Scenario: List available devices
- **WHEN** user opens meeting detail page
- **THEN** system SHALL enumerate all audio input devices
- **AND** display them in a dropdown selector
- **AND** exclude virtual/system devices like "Stereo Mix"
#### Scenario: Refresh device list
- **WHEN** user clicks refresh button or device is connected/disconnected
- **THEN** system SHALL re-enumerate devices
- **AND** update dropdown options
- **AND** preserve current selection if still available
#### Scenario: Device label display
- **WHEN** devices are listed
- **THEN** each device SHALL display its friendly name (label)
- **AND** indicate if it's the system default device
### Requirement: Manual Device Selection
The frontend SHALL allow users to manually select their preferred audio input device.
#### Scenario: Select device from dropdown
- **WHEN** user selects a device from dropdown
- **THEN** system SHALL update selected device state
- **AND** start volume monitoring on new device
- **AND** save selection to localStorage
#### Scenario: Load saved preference
- **WHEN** meeting detail page loads
- **THEN** system SHALL check localStorage for saved device preference
- **AND** if saved device is available, auto-select it
- **AND** if saved device unavailable, fall back to system default
#### Scenario: Selected device unavailable
- **WHEN** previously selected device is no longer available
- **THEN** system SHALL show warning message
- **AND** fall back to system default device
- **AND** prompt user to select new device
### Requirement: Real-time Volume Indicator
The frontend SHALL display real-time audio input level from the selected microphone.
#### Scenario: Display volume meter
- **WHEN** a device is selected
- **THEN** system SHALL show animated volume meter
- **AND** update meter at least 10 times per second
- **AND** display level as percentage (0-100%)
#### Scenario: Volume meter accuracy
- **WHEN** user speaks into microphone
- **THEN** volume meter SHALL reflect actual audio amplitude
- **AND** peak levels SHALL be visually distinct
#### Scenario: Muted or silent input
- **WHEN** no audio input detected for 3 seconds
- **THEN** volume meter SHALL show minimal/zero level
- **AND** optionally show "No input detected" hint
### Requirement: Audio Test Recording
The frontend SHALL allow users to record a short test audio clip.
#### Scenario: Start test recording
- **WHEN** user clicks "Test Recording" button
- **THEN** system SHALL start recording from selected device
- **AND** button SHALL change to "Stop" with countdown timer
- **AND** recording SHALL auto-stop after 5 seconds
#### Scenario: Stop test recording
- **WHEN** recording reaches 5 seconds or user clicks stop
- **THEN** recording SHALL stop
- **AND** audio blob SHALL be stored in memory
- **AND** "Play Test" button SHALL become enabled
#### Scenario: Recording indicator
- **WHEN** test recording is in progress
- **THEN** UI SHALL show recording indicator (pulsing dot)
- **AND** remaining time SHALL be displayed
### Requirement: Test Audio Playback
The frontend SHALL allow users to play back their test recording.
#### Scenario: Play test recording
- **WHEN** user clicks "Play Test" button
- **THEN** system SHALL play the recorded audio through default output
- **AND** button SHALL change to indicate playing state
- **AND** playback SHALL stop at end of recording
#### Scenario: No test recording available
- **WHEN** no test recording has been made
- **THEN** "Play Test" button SHALL be disabled
- **AND** tooltip SHALL indicate "Record a test first"
### Requirement: Integration with Main Recording
The main recording function SHALL use the user-selected audio device.
#### Scenario: Use selected device for recording
- **WHEN** user starts main recording
- **THEN** system SHALL use the device selected in audio settings panel
- **AND** if no device selected, use auto-selection logic
#### Scenario: Device changed during recording
- **WHEN** user changes device selection while recording
- **THEN** change SHALL NOT affect current recording
- **AND** new selection SHALL apply to next recording session
### Requirement: Audio Settings Panel UI
The frontend SHALL display audio settings in a collapsible panel.
#### Scenario: Panel visibility
- **WHEN** meeting detail page loads
- **THEN** audio settings panel SHALL be visible but collapsible
- **AND** panel state (expanded/collapsed) SHALL be saved
#### Scenario: Panel layout
- **WHEN** panel is expanded
- **THEN** it SHALL display:
- Device dropdown selector
- Volume meter visualization
- Test recording button
- Play test button
- Status indicator
#### Scenario: Compact mode
- **WHEN** panel is collapsed
- **THEN** it SHALL show only selected device name and expand button

View File

@@ -0,0 +1,125 @@
# Tasks: add-audio-device-selector
## Phase 1: Core Device Management
### Task 1.1: Add Audio Settings Panel HTML Structure
- [x] Add collapsible panel container in meeting-detail.html
- [x] Add device dropdown selector element
- [x] Add volume meter container (canvas or div bars)
- [x] Add test recording/playback buttons
- [x] Add status indicator element
- **Validation**: Panel renders correctly, all elements visible
### Task 1.2: Implement Device Enumeration
- [x] Create `enumerateAudioDevices()` function
- [x] Filter out virtual devices (Stereo Mix)
- [x] Populate dropdown with device labels
- [x] Mark default device in dropdown
- [x] Add device change event listener for hot-plug support
- **Validation**: All connected microphones appear in dropdown
### Task 1.3: Implement Device Selection Logic
- [x] Create `selectAudioDevice(deviceId)` function
- [x] Stop existing audio context when switching
- [x] Create new AudioContext with selected device
- [x] Save selection to localStorage
- [x] Handle device unavailable errors
- **Validation**: Selecting device updates state, persists after refresh
## Phase 2: Volume Monitoring
### Task 2.1: Implement Volume Meter
- [x] Create AudioContext and AnalyserNode
- [x] Connect selected device to analyser
- [x] Create volume calculation function (RMS or peak)
- [x] Implement requestAnimationFrame loop for updates
- [x] Render volume level as visual bar
- **Validation**: Meter responds to voice input, updates smoothly
### Task 2.2: Volume Meter Styling
- [x] Add CSS for volume meter bar
- [x] Add gradient colors (green → yellow → red)
- [x] Add percentage text display
- [x] Add "No input detected" indicator
- **Validation**: Visual feedback is clear and responsive
## Phase 3: Test Recording
### Task 3.1: Implement Test Recording Function
- [x] Create `startTestRecording()` function
- [x] Use MediaRecorder with selected device
- [x] Implement 5-second auto-stop timer
- [x] Store recording as Blob
- [x] Update UI during recording (countdown, indicator)
- **Validation**: Can record 5 seconds, blob created
### Task 3.2: Implement Test Playback Function
- [x] Create `playTestRecording()` function
- [x] Create Audio element from blob URL
- [x] Handle play/stop states
- [x] Update UI during playback
- [x] Clean up blob URL when done
- **Validation**: Recorded audio plays back correctly
### Task 3.3: Test Recording UI State Management
- [x] Disable recording button during recording
- [x] Show countdown timer during recording
- [x] Enable play button after recording
- [x] Disable test controls during main recording
- **Validation**: UI states transition correctly
## Phase 4: Integration
### Task 4.1: Integrate with Main Recording
- [x] Modify `startRecording()` to use selected device
- [x] Add fallback to auto-selection if no preference
- [x] Handle selected device being unavailable
- [x] Stop volume monitoring during main recording
- **Validation**: Main recording uses selected device
### Task 4.2: Add Panel Collapse/Expand
- [x] Add collapse toggle button
- [x] Save panel state to localStorage
- [x] Load panel state on page load
- [x] Stop volume monitoring when collapsed
- **Validation**: Panel remembers collapse state
### Task 4.3: Add Refresh Device List Button
- [x] Add refresh icon button
- [x] Re-enumerate devices on click
- [x] Preserve selection if still available
- [x] Update dropdown options
- **Validation**: New devices appear after refresh
## Phase 5: Polish & Error Handling
### Task 5.1: Error Handling
- [x] Handle "No devices found" state
- [x] Handle permission denied errors
- [x] Handle device disconnection during use
- [x] Show user-friendly error messages (Chinese)
- **Validation**: All error states show appropriate messages
### Task 5.2: Localization
- [x] Add Chinese labels for all UI elements
- [x] Add Chinese error messages
- [x] Add tooltips for buttons
- **Validation**: All text is in Traditional Chinese
### Task 5.3: Testing & Documentation
- [x] Manual testing with multiple microphones
- [x] Test USB microphone hot-plug
- [x] Test headset microphone switching
- [x] Update DEPLOYMENT.md if needed
- **Validation**: Feature works with various microphone types
## Dependencies
- Task 1.2 depends on Task 1.1
- Task 2.1 depends on Task 1.3
- Task 3.1 depends on Task 1.3
- Task 4.1 depends on Tasks 1.3, 3.1
- Phase 5 depends on all previous phases
## Parallelizable Work
- Task 1.1 (HTML) and Task 2.2 (CSS) can run in parallel
- Task 3.1 (Recording) and Task 2.1 (Volume) can run in parallel after Task 1.3

View File

@@ -0,0 +1,115 @@
# Design: Embedded Backend Packaging
## Context
Meeting Assistant uses a three-tier architecture: Electron Client → FastAPI Middleware → MySQL/Dify. For enterprise deployment, administrators want to distribute a single executable that users can run without additional setup. The backend must still connect to remote MySQL and Dify services (no local database).
**Stakeholders:**
- Enterprise IT administrators (simplified deployment)
- End users (double-click to run)
- Developers (maintain backward compatibility)
## Goals / Non-Goals
### Goals
- Package backend as a sidecar executable using PyInstaller
- Electron manages backend lifecycle (start on launch, stop on close)
- Single `config.json` for all configuration (frontend, backend, whisper)
- Health check ensures backend is ready before showing UI
- Backward compatible with existing separate-deployment mode
- Show Whisper model download progress to improve UX
### Non-Goals
- Embedding MySQL database (still remote)
- Embedding LLM model (still uses Dify API)
- Removing external authentication (still requires company SSO)
- Pre-bundling Whisper model (user downloads on first run)
## Decisions
### Decision 1: Use PyInstaller for Backend Packaging
**What:** Package FastAPI + uvicorn as standalone executable using PyInstaller `--onedir` mode.
**Why:**
- Consistent with existing transcriber sidecar approach
- `--onedir` provides faster startup than `--onefile`
- Team already has PyInstaller expertise
**Alternatives considered:**
- Nuitka: Better optimization but longer build times
- cx_Freeze: Less community support for FastAPI
### Decision 2: Configuration via Extended config.json
**What:** Add `backend` section to existing `config.json` with database, API, and auth settings.
**Why:**
- Single configuration file for users to manage
- Runtime modifiable without rebuilding
- Consistent with existing whisper config pattern
**Schema:**
```json
{
"apiBaseUrl": "http://localhost:8000/api",
"backend": {
"embedded": true,
"host": "127.0.0.1",
"port": 8000,
"database": { "host": "", "port": 33306, "user": "", "password": "", "database": "" },
"externalApis": { "authApiUrl": "", "difyApiUrl": "", "difyApiKey": "", "difySttApiKey": "" },
"auth": { "adminEmail": "", "jwtSecret": "", "jwtExpireHours": 24 }
}
}
```
### Decision 3: Health Check Before Window Display
**What:** Electron polls `/api/health` endpoint before creating main window.
**Why:**
- Prevents "connection refused" errors on startup
- Provides clear feedback if backend fails to start
- Maximum 30 attempts with 1-second intervals
### Decision 4: Backward Compatibility via Feature Flag
**What:** `backend.embedded: false` (default) preserves existing behavior; `true` enables embedded mode.
**Why:**
- Existing deployments continue working unchanged
- Gradual migration path for enterprises
- Same codebase supports both deployment models
### Decision 5: Huggingface Hub Progress Callback for Model Download
**What:** Intercept huggingface_hub download progress and emit JSON status messages.
**Why:**
- faster-whisper uses huggingface_hub internally
- Can emit progress without modifying faster-whisper source
- JSON format consistent with existing sidecar protocol
## Risks / Trade-offs
| Risk | Impact | Mitigation |
|------|--------|------------|
| PyInstaller hidden imports missing | Backend fails to start | Comprehensive hidden-import list; test on clean Windows |
| Config file contains sensitive data | Security exposure | Document security best practices; consider encryption |
| Backend startup timeout | Poor UX | Increase timeout; show loading indicator |
| Port 8000 already in use | Backend fails | Allow configurable port; detect and report conflicts |
## Migration Plan
### For New Deployments (All-in-One)
1. Build with `--embedded-backend` flag
2. Configure `config.json` with database/API credentials
3. Distribute single exe to users
### For Existing Deployments (Separate Backend)
1. No changes required
2. Ensure `backend.embedded: false` in config
3. Continue using existing backend deployment
### Rollback
- Set `backend.embedded: false` to disable embedded backend
- Deploy backend separately as before
## Open Questions
- Should we add config validation UI on first startup?
- Should backend port be auto-discovered if 8000 is in use?

View File

@@ -0,0 +1,28 @@
# Change: Add Embedded Backend Packaging for All-in-One Deployment
## Why
Currently, deploying Meeting Assistant requires setting up both the Electron client and a separate backend server. For enterprise internal deployment, users want a simpler experience: **double-click the exe and it works** without needing to understand or configure backend services separately.
Additionally, when users first run the packaged application, the Whisper model download (~1.5GB) shows only "loading_model" status with no progress indication, causing confusion about whether the download is actually happening.
## What Changes
- **New capability: Embedded Backend** - Package FastAPI backend as a sidecar managed by Electron
- **Backend sidecar management** - Electron starts/stops backend process automatically
- **Health check mechanism** - Wait for backend readiness before loading frontend
- **Configuration unification** - All settings (DB, API keys, auth) in single `config.json`
- **Backward compatible** - Existing deployment method (separate backend) still works via `backend.embedded: false` flag
- **Model download progress** - Show real-time download percentage for Whisper model
## Impact
- Affected specs: `embedded-backend` (new), `transcription` (modified)
- Affected code:
- `backend/run_server.py` (new) - Backend entry point for packaging
- `backend/build.py` (new) - PyInstaller build script
- `backend/app/config.py` - Support frozen executable paths
- `client/src/main.js` - Backend sidecar management
- `client/src/preload.js` - Expose backend status API
- `client/config.json` - Extended configuration schema
- `client/package.json` - Build configuration for backend resources
- `sidecar/transcriber.py` - Model download progress reporting
- `scripts/build-client.bat` - Integrated build script
- `scripts/build-all.ps1` - PowerShell build script

View File

@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: Embedded Backend Packaging
The FastAPI backend SHALL be packaged as a standalone executable using PyInstaller for all-in-one deployment.
#### Scenario: Backend executable creation
- **WHEN** build script runs with embedded backend flag
- **THEN** PyInstaller SHALL create `backend/dist/backend/backend.exe` containing FastAPI, uvicorn, and all dependencies
#### Scenario: Backend executable startup
- **WHEN** backend executable is launched
- **THEN** it SHALL read configuration from `config.json` in the same directory
- **AND** start uvicorn server on configured host and port
### Requirement: Electron Backend Sidecar Management
The Electron main process SHALL manage the embedded backend as a sidecar process.
#### Scenario: Start backend on app launch
- **WHEN** Electron app launches with `backend.embedded: true` in config
- **THEN** main process SHALL spawn backend executable as child process
- **AND** pass configuration via environment variables
#### Scenario: Skip backend when disabled
- **WHEN** Electron app launches with `backend.embedded: false` in config
- **THEN** main process SHALL NOT spawn backend executable
- **AND** frontend SHALL connect to remote backend via `apiBaseUrl`
#### Scenario: Terminate backend on app close
- **WHEN** user closes Electron app
- **THEN** main process SHALL send SIGTERM to backend process
- **AND** force kill after 5 seconds if still running
### Requirement: Backend Health Check
The Electron main process SHALL verify backend readiness before showing the main window.
#### Scenario: Health check success
- **WHEN** backend `/api/health` returns HTTP 200
- **THEN** main process SHALL proceed to create main window
- **AND** set `backendReady` state to true
#### Scenario: Health check timeout
- **WHEN** backend does not respond within 30 seconds (30 attempts, 1s interval)
- **THEN** main process SHALL display error dialog
- **AND** log detailed error for debugging
#### Scenario: Health check polling
- **WHEN** health check attempt fails
- **THEN** main process SHALL retry after 1 second
- **AND** log attempt number for debugging
### Requirement: Unified Configuration Schema
All configuration for frontend, backend, and whisper SHALL be in a single `config.json` file.
#### Scenario: Backend configuration loading
- **WHEN** backend sidecar starts
- **THEN** it SHALL read database credentials from `config.json` backend.database section
- **AND** read API keys from `config.json` backend.externalApis section
- **AND** read auth settings from `config.json` backend.auth section
#### Scenario: Configuration priority
- **WHEN** both environment variable and config.json value exist
- **THEN** environment variable SHALL take precedence
#### Scenario: Default values
- **WHEN** configuration value is not specified
- **THEN** system SHALL use sensible defaults (host: 127.0.0.1, port: 8000)
### Requirement: Backend Status API
The Electron app SHALL expose backend status to the renderer process.
#### Scenario: Get backend status
- **WHEN** renderer calls `window.electronAPI.getBackendStatus()`
- **THEN** it SHALL return object with `ready` boolean and `url` string
#### Scenario: Backend status in UI
- **WHEN** backend is starting
- **THEN** UI MAY display loading indicator
### Requirement: Backward Compatibility
The embedded backend feature SHALL NOT break existing separate-deployment mode.
#### Scenario: Separate deployment unchanged
- **WHEN** `backend.embedded` is false or undefined
- **THEN** system SHALL behave exactly as before this change
- **AND** frontend connects to `apiBaseUrl` without spawning local backend
#### Scenario: Existing scripts work
- **WHEN** user runs `./start.sh start` or `./scripts/setup-backend.sh`
- **THEN** backend SHALL start normally as standalone server

View File

@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: Model Download Progress Display
The sidecar SHALL report Whisper model download progress to enable UI feedback.
#### Scenario: Emit download start
- **WHEN** Whisper model download begins
- **THEN** sidecar SHALL emit JSON to stdout: `{"status": "downloading_model", "model": "<size>", "progress": 0, "total_mb": <size>}`
#### Scenario: Emit download progress
- **WHEN** download progress updates
- **THEN** sidecar SHALL emit JSON: `{"status": "downloading_model", "progress": <percent>, "downloaded_mb": <current>, "total_mb": <total>}`
- **AND** progress updates SHALL occur at least every 5% or every 5 seconds
#### Scenario: Emit download complete
- **WHEN** model download completes
- **THEN** sidecar SHALL emit JSON: `{"status": "model_downloaded", "model": "<size>"}`
- **AND** proceed to model loading
#### Scenario: Skip download for cached model
- **WHEN** model already exists in huggingface cache
- **THEN** sidecar SHALL NOT emit download progress messages
- **AND** proceed directly to loading
### Requirement: Frontend Model Download Progress Display
The Electron frontend SHALL display model download progress to users.
#### Scenario: Show download progress in transcript panel
- **WHEN** sidecar emits download progress
- **THEN** whisper status element SHALL display download percentage and size
- **AND** format: "Downloading: XX% (YYY MB / ZZZ MB)"
#### Scenario: Show download complete
- **WHEN** sidecar emits model_downloaded status
- **THEN** whisper status element SHALL briefly show "Model downloaded"
- **AND** transition to loading state
#### Scenario: Forward progress events via IPC
- **WHEN** main process receives download progress from sidecar
- **THEN** it SHALL forward to renderer via `model-download-progress` IPC channel

View File

@@ -0,0 +1,39 @@
# Tasks: Add Embedded Backend Packaging
## 1. Backend Packaging Infrastructure
- [x] 1.1 Create `backend/run_server.py` - Entry point that loads config and starts uvicorn
- [x] 1.2 Create `backend/build.py` - PyInstaller build script with hidden imports
- [x] 1.3 Modify `backend/app/config.py` - Support frozen executable path detection
- [ ] 1.4 Test backend executable runs standalone on Windows
## 2. Electron Backend Sidecar Management
- [x] 2.1 Add `backendProcess` and `backendReady` state variables in `main.js`
- [x] 2.2 Implement `startBackendSidecar()` function
- [x] 2.3 Implement `waitForBackendReady()` health check polling
- [x] 2.4 Modify `app.whenReady()` to start backend before window
- [x] 2.5 Modify window close handler to terminate backend process
- [x] 2.6 Add `get-backend-status` IPC handler
## 3. Configuration Schema Extension
- [x] 3.1 Extend `client/config.json` with `backend` section
- [x] 3.2 Modify `client/src/preload.js` to expose backend status API
- [x] 3.3 Add configuration loading in backend entry point
- [ ] 3.4 Document configuration options in DEPLOYMENT.md
## 4. Build Script Integration
- [x] 4.1 Modify `scripts/build-client.bat` to build backend sidecar
- [ ] 4.2 Modify `scripts/build-all.ps1` to build backend sidecar
- [x] 4.3 Update `client/package.json` extraResources for backend
- [x] 4.4 Add `--embedded-backend` flag to build scripts
## 5. Model Download Progress Display
- [x] 5.1 Modify `sidecar/transcriber.py` to emit download progress JSON
- [x] 5.2 Add progress event forwarding in `main.js`
- [x] 5.3 Expose `onModelDownloadProgress` in `preload.js`
- [x] 5.4 Update `meeting-detail.html` to display download progress
## 6. Testing and Documentation
- [ ] 6.1 Test embedded mode on clean Windows machine
- [ ] 6.2 Test backward compatibility (embedded: false)
- [ ] 6.3 Test model download progress display
- [ ] 6.4 Update DEPLOYMENT.md with all-in-one deployment instructions

View File

@@ -0,0 +1,18 @@
# Change: Add Flexible Deployment Options
## Why
Enterprise deployment environments vary significantly. Some networks block MySQL port 33306, preventing access to cloud databases. Additionally, the current portable executable extracts to a random folder in `%TEMP%`, causing Windows Defender warnings on each launch and potential permission issues.
## What Changes
- **SQLite database support** - Allow choosing between MySQL (cloud) and SQLite (local) databases at build time via `--database-type` parameter
- **Fixed portable extraction path** - Configure `unpackDirName` to use a predictable folder name instead of random UUID
## Impact
- Affected specs: `embedded-backend` (modified)
- Affected code:
- `client/config.json` - Add `database.type` and `database.sqlitePath` fields
- `client/package.json` - Add `unpackDirName` to portable configuration
- `backend/app/config.py` - Add `DB_TYPE` and `SQLITE_PATH` settings
- `backend/app/database.py` - Conditional SQLite/MySQL initialization
- `backend/run_server.py` - Pass database type environment variables
- `scripts/build-client.bat` - Add `--database-type` parameter

View File

@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: SQLite Database Support
The backend SHALL support SQLite as an alternative to MySQL for offline/standalone deployments.
#### Scenario: SQLite mode initialization
- **WHEN** `database.type` is set to `"sqlite"` in config.json
- **THEN** backend SHALL create SQLite database at `database.sqlitePath`
- **AND** initialize all required tables using SQLite-compatible syntax
#### Scenario: MySQL mode initialization
- **WHEN** `database.type` is set to `"mysql"` or not specified in config.json
- **THEN** backend SHALL connect to MySQL using credentials from `database` section
- **AND** behave exactly as before this change
#### Scenario: SQLite thread safety
- **WHEN** multiple concurrent requests access SQLite database
- **THEN** backend SHALL use thread lock to serialize database operations
- **AND** use `check_same_thread=False` for SQLite connection
#### Scenario: SQLite data persistence
- **WHEN** app is closed and reopened
- **THEN** all meeting data SHALL persist in SQLite file
- **AND** be accessible on next launch
### Requirement: Portable Extraction Path Configuration
The portable Windows build SHALL extract to a predictable folder name.
#### Scenario: Fixed extraction folder
- **WHEN** portable executable starts
- **THEN** it SHALL extract to `%TEMP%\Meeting-Assistant` instead of random UUID folder
#### Scenario: Windows Defender consistency
- **WHEN** user launches portable executable multiple times
- **THEN** Windows Defender SHALL NOT prompt for permission each time
- **BECAUSE** extraction path is consistent across launches
## MODIFIED Requirements
### Requirement: Unified Configuration Schema
All configuration for frontend, backend, and whisper SHALL be in a single `config.json` file.
#### Scenario: Backend configuration loading
- **WHEN** backend sidecar starts
- **THEN** it SHALL read database type from `config.json` backend.database.type section
- **AND** read SQLite path from `config.json` backend.database.sqlitePath section (if SQLite mode)
- **AND** read database credentials from `config.json` backend.database section (if MySQL mode)
- **AND** read API keys from `config.json` backend.externalApis section
- **AND** read auth settings from `config.json` backend.auth section
#### Scenario: Configuration priority
- **WHEN** both environment variable and config.json value exist
- **THEN** environment variable SHALL take precedence
#### Scenario: Default values
- **WHEN** configuration value is not specified
- **THEN** system SHALL use sensible defaults (host: 127.0.0.1, port: 8000, database.type: mysql)

View File

@@ -0,0 +1,36 @@
# Tasks: Add Flexible Deployment Options
## 1. Portable Extraction Path
- [x] 1.1 Update `client/package.json` - Add `unpackDirName: "Meeting-Assistant"` to portable config
## 2. Configuration Schema for SQLite
- [x] 2.1 Update `client/config.json` - Add `database.type` field (default: "mysql")
- [x] 2.2 Update `client/config.json` - Add `database.sqlitePath` field (default: "data/meeting.db")
## 3. Backend Configuration
- [x] 3.1 Update `backend/app/config.py` - Add `DB_TYPE` setting
- [x] 3.2 Update `backend/app/config.py` - Add `SQLITE_PATH` setting
- [x] 3.3 Update `backend/run_server.py` - Pass `DB_TYPE` and `SQLITE_PATH` to environment
## 4. Database Abstraction Layer
- [x] 4.1 Refactor `backend/app/database.py` - Create `init_db()` dispatcher function
- [x] 4.2 Implement `init_sqlite()` - SQLite connection with row_factory
- [x] 4.3 Implement `init_mysql()` - Keep existing MySQL pool logic
- [x] 4.4 Create unified `get_db_cursor()` context manager for both backends
- [x] 4.5 Add SQLite table creation statements (convert MySQL syntax)
- [x] 4.6 Add thread lock for SQLite connection safety
## 5. Build Script Integration
- [x] 5.1 Update `scripts/build-client.bat` - Add `--database-type` parameter parsing
- [x] 5.2 Update `scripts/build-client.bat` - Add `update_config_database` function
- [x] 5.3 Update help message with new parameter
## 6. Testing
- [x] 6.1 Test SQLite mode - Create meeting, query, update, delete
- [x] 6.2 Test MySQL mode - Ensure backward compatibility
- [ ] 6.3 Test portable extraction to `%TEMP%\Meeting-Assistant` (requires Windows build)
## 7. Documentation
- [x] 7.1 Update DEPLOYMENT.md with SQLite mode instructions
- [x] 7.2 Update DEPLOYMENT.md with --database-type parameter
- [x] 7.3 Update DEPLOYMENT.md with portable extraction path info

View File

@@ -0,0 +1,133 @@
# audio-device-management Specification
## Purpose
TBD - created by archiving change add-audio-device-selector. Update Purpose after archive.
## Requirements
### Requirement: Audio Device Enumeration
The frontend SHALL enumerate and display all available audio input devices.
#### Scenario: List available devices
- **WHEN** user opens meeting detail page
- **THEN** system SHALL enumerate all audio input devices
- **AND** display them in a dropdown selector
- **AND** exclude virtual/system devices like "Stereo Mix"
#### Scenario: Refresh device list
- **WHEN** user clicks refresh button or device is connected/disconnected
- **THEN** system SHALL re-enumerate devices
- **AND** update dropdown options
- **AND** preserve current selection if still available
#### Scenario: Device label display
- **WHEN** devices are listed
- **THEN** each device SHALL display its friendly name (label)
- **AND** indicate if it's the system default device
### Requirement: Manual Device Selection
The frontend SHALL allow users to manually select their preferred audio input device.
#### Scenario: Select device from dropdown
- **WHEN** user selects a device from dropdown
- **THEN** system SHALL update selected device state
- **AND** start volume monitoring on new device
- **AND** save selection to localStorage
#### Scenario: Load saved preference
- **WHEN** meeting detail page loads
- **THEN** system SHALL check localStorage for saved device preference
- **AND** if saved device is available, auto-select it
- **AND** if saved device unavailable, fall back to system default
#### Scenario: Selected device unavailable
- **WHEN** previously selected device is no longer available
- **THEN** system SHALL show warning message
- **AND** fall back to system default device
- **AND** prompt user to select new device
### Requirement: Real-time Volume Indicator
The frontend SHALL display real-time audio input level from the selected microphone.
#### Scenario: Display volume meter
- **WHEN** a device is selected
- **THEN** system SHALL show animated volume meter
- **AND** update meter at least 10 times per second
- **AND** display level as percentage (0-100%)
#### Scenario: Volume meter accuracy
- **WHEN** user speaks into microphone
- **THEN** volume meter SHALL reflect actual audio amplitude
- **AND** peak levels SHALL be visually distinct
#### Scenario: Muted or silent input
- **WHEN** no audio input detected for 3 seconds
- **THEN** volume meter SHALL show minimal/zero level
- **AND** optionally show "No input detected" hint
### Requirement: Audio Test Recording
The frontend SHALL allow users to record a short test audio clip.
#### Scenario: Start test recording
- **WHEN** user clicks "Test Recording" button
- **THEN** system SHALL start recording from selected device
- **AND** button SHALL change to "Stop" with countdown timer
- **AND** recording SHALL auto-stop after 5 seconds
#### Scenario: Stop test recording
- **WHEN** recording reaches 5 seconds or user clicks stop
- **THEN** recording SHALL stop
- **AND** audio blob SHALL be stored in memory
- **AND** "Play Test" button SHALL become enabled
#### Scenario: Recording indicator
- **WHEN** test recording is in progress
- **THEN** UI SHALL show recording indicator (pulsing dot)
- **AND** remaining time SHALL be displayed
### Requirement: Test Audio Playback
The frontend SHALL allow users to play back their test recording.
#### Scenario: Play test recording
- **WHEN** user clicks "Play Test" button
- **THEN** system SHALL play the recorded audio through default output
- **AND** button SHALL change to indicate playing state
- **AND** playback SHALL stop at end of recording
#### Scenario: No test recording available
- **WHEN** no test recording has been made
- **THEN** "Play Test" button SHALL be disabled
- **AND** tooltip SHALL indicate "Record a test first"
### Requirement: Integration with Main Recording
The main recording function SHALL use the user-selected audio device.
#### Scenario: Use selected device for recording
- **WHEN** user starts main recording
- **THEN** system SHALL use the device selected in audio settings panel
- **AND** if no device selected, use auto-selection logic
#### Scenario: Device changed during recording
- **WHEN** user changes device selection while recording
- **THEN** change SHALL NOT affect current recording
- **AND** new selection SHALL apply to next recording session
### Requirement: Audio Settings Panel UI
The frontend SHALL display audio settings in a collapsible panel.
#### Scenario: Panel visibility
- **WHEN** meeting detail page loads
- **THEN** audio settings panel SHALL be visible but collapsible
- **AND** panel state (expanded/collapsed) SHALL be saved
#### Scenario: Panel layout
- **WHEN** panel is expanded
- **THEN** it SHALL display:
- Device dropdown selector
- Volume meter visualization
- Test recording button
- Play test button
- Status indicator
#### Scenario: Compact mode
- **WHEN** panel is collapsed
- **THEN** it SHALL show only selected device name and expand button

View File

@@ -0,0 +1,130 @@
# embedded-backend Specification
## Purpose
TBD - created by archiving change add-embedded-backend-packaging. Update Purpose after archive.
## Requirements
### Requirement: Embedded Backend Packaging
The FastAPI backend SHALL be packaged as a standalone executable using PyInstaller for all-in-one deployment.
#### Scenario: Backend executable creation
- **WHEN** build script runs with embedded backend flag
- **THEN** PyInstaller SHALL create `backend/dist/backend/backend.exe` containing FastAPI, uvicorn, and all dependencies
#### Scenario: Backend executable startup
- **WHEN** backend executable is launched
- **THEN** it SHALL read configuration from `config.json` in the same directory
- **AND** start uvicorn server on configured host and port
### Requirement: Electron Backend Sidecar Management
The Electron main process SHALL manage the embedded backend as a sidecar process.
#### Scenario: Start backend on app launch
- **WHEN** Electron app launches with `backend.embedded: true` in config
- **THEN** main process SHALL spawn backend executable as child process
- **AND** pass configuration via environment variables
#### Scenario: Skip backend when disabled
- **WHEN** Electron app launches with `backend.embedded: false` in config
- **THEN** main process SHALL NOT spawn backend executable
- **AND** frontend SHALL connect to remote backend via `apiBaseUrl`
#### Scenario: Terminate backend on app close
- **WHEN** user closes Electron app
- **THEN** main process SHALL send SIGTERM to backend process
- **AND** force kill after 5 seconds if still running
### Requirement: Backend Health Check
The Electron main process SHALL verify backend readiness before showing the main window.
#### Scenario: Health check success
- **WHEN** backend `/api/health` returns HTTP 200
- **THEN** main process SHALL proceed to create main window
- **AND** set `backendReady` state to true
#### Scenario: Health check timeout
- **WHEN** backend does not respond within 30 seconds (30 attempts, 1s interval)
- **THEN** main process SHALL display error dialog
- **AND** log detailed error for debugging
#### Scenario: Health check polling
- **WHEN** health check attempt fails
- **THEN** main process SHALL retry after 1 second
- **AND** log attempt number for debugging
### Requirement: Unified Configuration Schema
All configuration for frontend, backend, and whisper SHALL be in a single `config.json` file.
#### Scenario: Backend configuration loading
- **WHEN** backend sidecar starts
- **THEN** it SHALL read database type from `config.json` backend.database.type section
- **AND** read SQLite path from `config.json` backend.database.sqlitePath section (if SQLite mode)
- **AND** read database credentials from `config.json` backend.database section (if MySQL mode)
- **AND** read API keys from `config.json` backend.externalApis section
- **AND** read auth settings from `config.json` backend.auth section
#### Scenario: Configuration priority
- **WHEN** both environment variable and config.json value exist
- **THEN** environment variable SHALL take precedence
#### Scenario: Default values
- **WHEN** configuration value is not specified
- **THEN** system SHALL use sensible defaults (host: 127.0.0.1, port: 8000, database.type: mysql)
### Requirement: Backend Status API
The Electron app SHALL expose backend status to the renderer process.
#### Scenario: Get backend status
- **WHEN** renderer calls `window.electronAPI.getBackendStatus()`
- **THEN** it SHALL return object with `ready` boolean and `url` string
#### Scenario: Backend status in UI
- **WHEN** backend is starting
- **THEN** UI MAY display loading indicator
### Requirement: Backward Compatibility
The embedded backend feature SHALL NOT break existing separate-deployment mode.
#### Scenario: Separate deployment unchanged
- **WHEN** `backend.embedded` is false or undefined
- **THEN** system SHALL behave exactly as before this change
- **AND** frontend connects to `apiBaseUrl` without spawning local backend
#### Scenario: Existing scripts work
- **WHEN** user runs `./start.sh start` or `./scripts/setup-backend.sh`
- **THEN** backend SHALL start normally as standalone server
### Requirement: SQLite Database Support
The backend SHALL support SQLite as an alternative to MySQL for offline/standalone deployments.
#### Scenario: SQLite mode initialization
- **WHEN** `database.type` is set to `"sqlite"` in config.json
- **THEN** backend SHALL create SQLite database at `database.sqlitePath`
- **AND** initialize all required tables using SQLite-compatible syntax
#### Scenario: MySQL mode initialization
- **WHEN** `database.type` is set to `"mysql"` or not specified in config.json
- **THEN** backend SHALL connect to MySQL using credentials from `database` section
- **AND** behave exactly as before this change
#### Scenario: SQLite thread safety
- **WHEN** multiple concurrent requests access SQLite database
- **THEN** backend SHALL use thread lock to serialize database operations
- **AND** use `check_same_thread=False` for SQLite connection
#### Scenario: SQLite data persistence
- **WHEN** app is closed and reopened
- **THEN** all meeting data SHALL persist in SQLite file
- **AND** be accessible on next launch
### Requirement: Portable Extraction Path Configuration
The portable Windows build SHALL extract to a predictable folder name.
#### Scenario: Fixed extraction folder
- **WHEN** portable executable starts
- **THEN** it SHALL extract to `%TEMP%\Meeting-Assistant` instead of random UUID folder
#### Scenario: Windows Defender consistency
- **WHEN** user launches portable executable multiple times
- **THEN** Windows Defender SHALL NOT prompt for permission each time
- **BECAUSE** extraction path is consistent across launches

View File

@@ -175,3 +175,42 @@ The system SHALL support both real-time local transcription and file-based cloud
- **WHEN** transcription completes from either source
- **THEN** result SHALL be displayed in the same transcript area in meeting detail page
### Requirement: Model Download Progress Display
The sidecar SHALL report Whisper model download progress to enable UI feedback.
#### Scenario: Emit download start
- **WHEN** Whisper model download begins
- **THEN** sidecar SHALL emit JSON to stdout: `{"status": "downloading_model", "model": "<size>", "progress": 0, "total_mb": <size>}`
#### Scenario: Emit download progress
- **WHEN** download progress updates
- **THEN** sidecar SHALL emit JSON: `{"status": "downloading_model", "progress": <percent>, "downloaded_mb": <current>, "total_mb": <total>}`
- **AND** progress updates SHALL occur at least every 5% or every 5 seconds
#### Scenario: Emit download complete
- **WHEN** model download completes
- **THEN** sidecar SHALL emit JSON: `{"status": "model_downloaded", "model": "<size>"}`
- **AND** proceed to model loading
#### Scenario: Skip download for cached model
- **WHEN** model already exists in huggingface cache
- **THEN** sidecar SHALL NOT emit download progress messages
- **AND** proceed directly to loading
### Requirement: Frontend Model Download Progress Display
The Electron frontend SHALL display model download progress to users.
#### Scenario: Show download progress in transcript panel
- **WHEN** sidecar emits download progress
- **THEN** whisper status element SHALL display download percentage and size
- **AND** format: "Downloading: XX% (YYY MB / ZZZ MB)"
#### Scenario: Show download complete
- **WHEN** sidecar emits model_downloaded status
- **THEN** whisper status element SHALL briefly show "Model downloaded"
- **AND** transition to loading state
#### Scenario: Forward progress events via IPC
- **WHEN** main process receives download progress from sidecar
- **THEN** it SHALL forward to renderer via `model-download-progress` IPC channel

View File

@@ -18,11 +18,17 @@ set "SCRIPT_DIR=%~dp0"
set "PROJECT_DIR=%SCRIPT_DIR%.."
set "CLIENT_DIR=%PROJECT_DIR%\client"
set "SIDECAR_DIR=%PROJECT_DIR%\sidecar"
set "BACKEND_DIR=%PROJECT_DIR%\backend"
set "BUILD_DIR=%PROJECT_DIR%\build"
REM 預設配置
set "SKIP_SIDECAR=false"
set "SKIP_BACKEND=true"
set "EMBEDDED_BACKEND=false"
set "CLEAN_BUILD=false"
set "API_URL="
set "DATABASE_TYPE="
set "BUILD_TARGET=nsis"
REM 解析參數
set "COMMAND=help"
@@ -34,7 +40,12 @@ if /i "%~1"=="electron" (set "COMMAND=electron" & shift & goto :parse_args)
if /i "%~1"=="clean" (set "COMMAND=clean" & shift & goto :parse_args)
if /i "%~1"=="help" (set "COMMAND=help" & shift & goto :parse_args)
if /i "%~1"=="--skip-sidecar" (set "SKIP_SIDECAR=true" & shift & goto :parse_args)
if /i "%~1"=="--skip-backend" (set "SKIP_BACKEND=true" & shift & goto :parse_args)
if /i "%~1"=="--embedded-backend" (set "EMBEDDED_BACKEND=true" & set "SKIP_BACKEND=false" & shift & goto :parse_args)
if /i "%~1"=="--clean" (set "CLEAN_BUILD=true" & shift & goto :parse_args)
if /i "%~1"=="--api-url" (set "API_URL=%~2" & shift & shift & goto :parse_args)
if /i "%~1"=="--database-type" (set "DATABASE_TYPE=%~2" & shift & shift & goto :parse_args)
if /i "%~1"=="--target" (set "BUILD_TARGET=%~2" & shift & shift & goto :parse_args)
echo %RED%[ERROR]%NC% 未知參數: %~1
goto :show_help
@@ -93,6 +104,28 @@ if %errorlevel% equ 0 (
echo %RED%[ERROR]%NC% 需要 Python 3.10 或更高版本
exit /b 1
:update_config
if "%API_URL%"=="" goto :eof
echo %BLUE%[STEP]%NC% 更新 API URL 設定...
set "CONFIG_FILE=%CLIENT_DIR%\config.json"
if not exist "%CONFIG_FILE%" (
echo %YELLOW%[WARN]%NC% 找不到 config.json跳過 API URL 設定
goto :eof
)
REM 使用 PowerShell 更新 JSON
powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; $config.apiBaseUrl = '%API_URL%'; $config | ConvertTo-Json -Depth 10 | Set-Content '%CONFIG_FILE%' -Encoding UTF8"
if errorlevel 1 (
echo %RED%[ERROR]%NC% 更新 config.json 失敗
exit /b 1
)
echo %GREEN%[OK]%NC% API URL 已設定為: %API_URL%
goto :eof
:do_clean
echo %BLUE%[STEP]%NC% 清理建置目錄...
@@ -102,6 +135,10 @@ if exist "%SIDECAR_DIR%\dist" rmdir /s /q "%SIDECAR_DIR%\dist"
if exist "%SIDECAR_DIR%\build" rmdir /s /q "%SIDECAR_DIR%\build"
if exist "%SIDECAR_DIR%\venv" rmdir /s /q "%SIDECAR_DIR%\venv"
if exist "%SIDECAR_DIR%\*.spec" del /q "%SIDECAR_DIR%\*.spec"
if exist "%BACKEND_DIR%\dist" rmdir /s /q "%BACKEND_DIR%\dist"
if exist "%BACKEND_DIR%\build" rmdir /s /q "%BACKEND_DIR%\build"
if exist "%BACKEND_DIR%\venv" rmdir /s /q "%BACKEND_DIR%\venv"
if exist "%BACKEND_DIR%\*.spec" del /q "%BACKEND_DIR%\*.spec"
echo %GREEN%[OK]%NC% 建置目錄已清理
goto :eof
@@ -170,6 +207,167 @@ if exist "dist\transcriber" (
)
goto :eof
:setup_backend_venv
echo %BLUE%[STEP]%NC% 設置 Backend 建置環境...
cd /d "%BACKEND_DIR%"
if not exist "venv" (
echo %BLUE%[INFO]%NC% 創建虛擬環境...
%PYTHON_CMD% -m venv venv
)
echo %BLUE%[INFO]%NC% 安裝 Backend 依賴...
call venv\Scripts\activate.bat
python -m pip install --upgrade pip -q
python -m pip install -r requirements.txt -q
echo %BLUE%[INFO]%NC% 安裝 PyInstaller...
python -m pip install pyinstaller -q
echo %GREEN%[OK]%NC% Backend 建置環境就緒
goto :eof
:build_backend
echo %BLUE%[STEP]%NC% 打包 Backend (Python → 獨立執行檔)...
cd /d "%BACKEND_DIR%"
call venv\Scripts\activate.bat
if not exist "dist" mkdir dist
echo %BLUE%[INFO]%NC% 執行 PyInstaller...
echo %BLUE%[INFO]%NC% 這可能需要幾分鐘...
pyinstaller ^
--onedir ^
--name backend ^
--distpath dist ^
--workpath build ^
--specpath . ^
--noconfirm ^
--clean ^
--log-level WARN ^
--console ^
--hidden-import=uvicorn ^
--hidden-import=uvicorn.logging ^
--hidden-import=uvicorn.loops ^
--hidden-import=uvicorn.loops.auto ^
--hidden-import=uvicorn.protocols ^
--hidden-import=uvicorn.protocols.http ^
--hidden-import=uvicorn.protocols.http.auto ^
--hidden-import=uvicorn.protocols.websockets ^
--hidden-import=uvicorn.protocols.websockets.auto ^
--hidden-import=uvicorn.lifespan ^
--hidden-import=uvicorn.lifespan.on ^
--hidden-import=uvicorn.lifespan.off ^
--hidden-import=fastapi ^
--hidden-import=starlette ^
--hidden-import=pydantic ^
--hidden-import=pydantic_core ^
--hidden-import=mysql.connector ^
--hidden-import=mysql.connector.pooling ^
--hidden-import=sqlite3 ^
--hidden-import=httpx ^
--hidden-import=httpcore ^
--hidden-import=jose ^
--hidden-import=jose.jwt ^
--hidden-import=cryptography ^
--hidden-import=openpyxl ^
--hidden-import=multipart ^
--hidden-import=python_multipart ^
--hidden-import=dotenv ^
--hidden-import=tzdata ^
--hidden-import=app ^
--hidden-import=app.main ^
--hidden-import=app.config ^
--hidden-import=app.database ^
--hidden-import=app.models ^
--hidden-import=app.models.schemas ^
--hidden-import=app.routers ^
--hidden-import=app.routers.auth ^
--hidden-import=app.routers.meetings ^
--hidden-import=app.routers.ai ^
--hidden-import=app.routers.export ^
--hidden-import=app.routers.sidecar ^
--hidden-import=app.sidecar_manager ^
--collect-data=pydantic ^
--collect-data=uvicorn ^
run_server.py
if exist "dist\backend" (
echo %BLUE%[INFO]%NC% 複製 template 目錄...
if exist "template" (
xcopy /s /e /y "template\*" "dist\backend\template\" >nul 2>&1
)
if not exist "dist\backend\record" mkdir "dist\backend\record"
echo %GREEN%[OK]%NC% Backend 打包完成: %BACKEND_DIR%\dist\backend
) else (
echo %RED%[ERROR]%NC% Backend 打包失敗
exit /b 1
)
goto :eof
:update_config_embedded
REM 更新 config.json 以啟用 embedded backend
if "%EMBEDDED_BACKEND%"=="false" goto :eof
echo %BLUE%[STEP]%NC% 啟用內嵌後端模式...
set "CONFIG_FILE=%CLIENT_DIR%\config.json"
if not exist "%CONFIG_FILE%" (
echo %YELLOW%[WARN]%NC% 找不到 config.json跳過內嵌模式設定
goto :eof
)
REM 使用 PowerShell 更新 backend.embedded = true (使用 UTF8 without BOM)
powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; if (-not $config.backend) { $config | Add-Member -NotePropertyName 'backend' -NotePropertyValue @{} }; $config.backend.embedded = $true; $json = $config | ConvertTo-Json -Depth 10; [System.IO.File]::WriteAllText('%CONFIG_FILE%', $json, [System.Text.UTF8Encoding]::new($false))"
if errorlevel 1 (
echo %RED%[ERROR]%NC% 更新 config.json embedded 設定失敗
exit /b 1
)
echo %GREEN%[OK]%NC% 已啟用內嵌後端模式
goto :eof
:update_config_database
REM 更新 config.json 的資料庫類型
if "%DATABASE_TYPE%"=="" goto :eof
echo %BLUE%[STEP]%NC% 設定資料庫類型...
set "CONFIG_FILE=%CLIENT_DIR%\config.json"
if not exist "%CONFIG_FILE%" (
echo %YELLOW%[WARN]%NC% 找不到 config.json跳過資料庫類型設定
goto :eof
)
REM 驗證資料庫類型
if /i not "%DATABASE_TYPE%"=="mysql" if /i not "%DATABASE_TYPE%"=="sqlite" (
echo %RED%[ERROR]%NC% 無效的資料庫類型: %DATABASE_TYPE%
echo %BLUE%[INFO]%NC% 有效選項: mysql, sqlite
exit /b 1
)
REM 使用 PowerShell 更新 database.type (使用 UTF8 without BOM)
if /i "%DATABASE_TYPE%"=="sqlite" (
REM SQLite 模式: 設定 type=sqlite清空 MySQL 連線資訊
powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; $config.backend.database.type = 'sqlite'; $config.backend.database.host = ''; $config.backend.database.user = ''; $config.backend.database.password = ''; $config.backend.database.database = ''; $json = $config | ConvertTo-Json -Depth 10; [System.IO.File]::WriteAllText('%CONFIG_FILE%', $json, [System.Text.UTF8Encoding]::new($false))"
echo %GREEN%[OK]%NC% 資料庫類型已設定為: SQLite ^(本地模式^)
) else (
REM MySQL 模式: 僅設定 type=mysql保留連線資訊
powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; $config.backend.database.type = 'mysql'; $json = $config | ConvertTo-Json -Depth 10; [System.IO.File]::WriteAllText('%CONFIG_FILE%', $json, [System.Text.UTF8Encoding]::new($false))"
echo %GREEN%[OK]%NC% 資料庫類型已設定為: MySQL ^(雲端模式^)
)
if errorlevel 1 (
echo %RED%[ERROR]%NC% 更新 config.json database.type 失敗
exit /b 1
)
goto :eof
:setup_client
echo %BLUE%[STEP]%NC% 設置前端建置環境...
@@ -205,7 +403,21 @@ echo %BLUE%[STEP]%NC% 打包 Electron 應用...
cd /d "%CLIENT_DIR%"
echo %BLUE%[INFO]%NC% 目標平台: Windows (Portable)
REM 驗證 BUILD_TARGET
if /i "%BUILD_TARGET%"=="nsis" goto :valid_target
if /i "%BUILD_TARGET%"=="portable" goto :valid_target
echo %RED%[ERROR]%NC% 無效的打包目標: %BUILD_TARGET%
echo %BLUE%[INFO]%NC% 有效選項: nsis, portable
exit /b 1
:valid_target
if /i "%BUILD_TARGET%"=="nsis" (
echo %BLUE%[INFO]%NC% 目標平台: Windows NSIS 安裝檔 - 推薦
) else (
echo %BLUE%[INFO]%NC% 目標平台: Windows Portable
echo %YELLOW%[WARN]%NC% 注意: Portable 模式的臨時資料夾會在關閉時清空
echo %YELLOW%[WARN]%NC% SQLite 資料庫已自動儲存到 %%APPDATA%%\Meeting-Assistant
)
REM 清理可能損壞的 electron-builder 快取(解決 symlink 問題)
set "EB_CACHE=%LOCALAPPDATA%\electron-builder\Cache\winCodeSign"
@@ -218,9 +430,9 @@ echo %BLUE%[INFO]%NC% 執行 electron-builder...
REM 使用 npm run build 或直接執行 node_modules 中的 electron-builder
if exist "node_modules\.bin\electron-builder.cmd" (
call "node_modules\.bin\electron-builder.cmd" --win
call "node_modules\.bin\electron-builder.cmd" --win %BUILD_TARGET%
) else (
call npx electron-builder --win
call npx electron-builder --win %BUILD_TARGET%
)
if errorlevel 1 (
@@ -263,9 +475,26 @@ dir /b "%BUILD_DIR%"
echo.
echo %GREEN%[OK]%NC% 打包完成!
echo.
echo Windows 使用說明:
echo 1. 找到 build\ 中的 .exe 檔案
echo 2. 直接執行即可,無需安裝
if /i "%BUILD_TARGET%"=="nsis" goto :show_nsis_help
goto :show_portable_help
:show_nsis_help
echo Windows 使用說明 - NSIS 安裝檔
echo 1. 找到 build\ 中的 *-setup.exe 檔案
echo 2. 執行安裝檔,選擇安裝目錄
echo 3. 安裝後從開始選單或桌面捷徑啟動
echo 4. 資料會持久保存在安裝目錄中
goto :end_help
:show_portable_help
echo Windows 使用說明 - Portable
echo 1. 找到 build\ 中的 *-portable.exe 檔案
echo 2. 直接執行,無需安裝
echo 3. 注意 - 關閉程式後臨時檔案會清空
echo 4. SQLite 資料庫保存在 %%APPDATA%%\Meeting-Assistant
goto :end_help
:end_help
echo.
goto :eof
@@ -276,11 +505,30 @@ if errorlevel 1 exit /b 1
if "%CLEAN_BUILD%"=="true" call :do_clean
REM 更新 API URL如果有指定
call :update_config
REM 更新 embedded backend 設定(如果有指定)
call :update_config_embedded
REM 內嵌後端模式預設使用 SQLite除非明確指定 mysql
if "%EMBEDDED_BACKEND%"=="true" (
if "%DATABASE_TYPE%"=="" set "DATABASE_TYPE=sqlite"
)
REM 更新資料庫類型設定
call :update_config_database
if "%SKIP_SIDECAR%"=="false" (
call :setup_sidecar_venv
call :build_sidecar
)
if "%SKIP_BACKEND%"=="false" (
call :setup_backend_venv
call :build_backend
)
call :setup_client
call :build_electron
call :finalize_build
@@ -337,16 +585,37 @@ echo clean 清理建置目錄
echo help 顯示此幫助訊息
echo.
echo 選項:
echo --api-url URL 後端 API URL
echo --skip-sidecar 跳過 Sidecar 打包
echo --skip-backend 跳過 Backend 打包
echo --embedded-backend 打包內嵌後端,預設使用 SQLite
echo --database-type TYPE 資料庫類型: sqlite 或 mysql
echo --target TARGET 打包目標: nsis 或 portable
echo --clean 建置前先清理
echo.
echo 範例:
echo %~nx0 build 完整建置
echo %~nx0 build --embedded-backend 全包部署SQLite 本地資料庫
echo %~nx0 build --embedded-backend --database-type mysql 全包部署MySQL 雲端
echo %~nx0 build --target portable 打包為 Portable
echo %~nx0 sidecar 僅打包 Sidecar
echo %~nx0 electron --skip-sidecar 僅打包 Electron
echo.
echo 部署模式:
echo 分離部署: 前端連接遠端後端,使用 --api-url 指定後端地址
echo 全包部署: 使用 --embedded-backend預設 SQLite 本地資料庫
echo.
echo 打包目標:
echo nsis: 產生安裝檔,推薦正式使用
echo portable: 產生免安裝 exeSQLite 資料庫儲存到 %%APPDATA%%
echo.
echo 資料庫模式 - 全包部署時:
echo SQLite 預設: 本地資料庫,完全離線運作
echo MySQL: 需明確指定 --database-type mysql連接雲端資料庫
echo.
echo 注意:
echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間
echo - 全包部署需要額外約 50MB 空間用於後端
echo - 確保有足夠的磁碟空間 (建議 5GB+)
echo.
goto :eof

View File

@@ -1,29 +1,66 @@
#!/usr/bin/env python3
"""
Build script for creating standalone transcriber executable using PyInstaller.
Uses --onedir mode for faster startup compared to --onefile.
"""
import subprocess
import sys
import os
import shutil
def clean_build_cache(script_dir):
"""Clean old build artifacts that may cause stale spec file issues."""
dirs_to_clean = [
os.path.join(script_dir, "build"),
os.path.join(script_dir, "__pycache__"),
]
files_to_clean = [
os.path.join(script_dir, "build", "transcriber.spec"),
]
for f in files_to_clean:
if os.path.exists(f):
print(f"Removing old spec file: {f}")
os.remove(f)
for d in dirs_to_clean:
pycache = os.path.join(d)
if os.path.exists(pycache) and "__pycache__" in pycache:
print(f"Removing cache: {pycache}")
shutil.rmtree(pycache)
def build():
"""Build the transcriber executable."""
# PyInstaller command
script_dir = os.path.dirname(os.path.abspath(__file__))
# Clean old build cache to avoid stale spec file issues
clean_build_cache(script_dir)
# PyInstaller command with --onedir for faster startup
cmd = [
sys.executable, "-m", "PyInstaller",
"--onefile",
"--onedir",
"--clean", # Clean PyInstaller cache before building
"--name", "transcriber",
"--distpath", "dist",
"--workpath", "build",
"--specpath", "build",
# Core dependencies
"--hidden-import", "faster_whisper",
"--hidden-import", "opencc",
"--hidden-import", "numpy",
"--hidden-import", "ctranslate2",
"--hidden-import", "huggingface_hub",
"--hidden-import", "huggingface_hub.utils",
"--hidden-import", "tokenizers",
# ONNX Runtime for VAD
"--hidden-import", "onnxruntime",
# Audio processing
"--hidden-import", "wave",
# Collect data files
"--collect-data", "faster_whisper",
"--collect-data", "opencc",
"transcriber.py"
@@ -32,10 +69,12 @@ def build():
print("Building transcriber executable...")
print(f"Command: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=os.path.dirname(os.path.abspath(__file__)))
result = subprocess.run(cmd, cwd=script_dir)
if result.returncode == 0:
print("\nBuild successful! Executable created at: dist/transcriber")
print("\nBuild successful!")
print("Executable created at: dist/transcriber/transcriber.exe (Windows) or dist/transcriber/transcriber (Linux)")
print("\nNote: The Whisper model will be downloaded on first run if not cached.")
else:
print("\nBuild failed!")
sys.exit(1)

View File

@@ -31,6 +31,8 @@ try:
from faster_whisper import WhisperModel
import opencc
import numpy as np
from huggingface_hub import snapshot_download, hf_hub_download
from huggingface_hub.utils import tqdm as hf_tqdm
except ImportError as e:
print(json.dumps({"error": f"Missing dependency: {e}"}), file=sys.stderr)
sys.exit(1)
@@ -43,6 +45,171 @@ except ImportError:
ONNX_AVAILABLE = False
def check_and_download_whisper_model(model_size: str) -> bool:
"""
Check if Whisper model is cached, download with progress if not.
Returns:
True if model is ready (cached or downloaded), False on error
"""
# faster-whisper model repository mapping
repo_id = f"Systran/faster-whisper-{model_size}"
# Check if model is already cached
cache_dir = Path.home() / ".cache" / "huggingface" / "hub"
repo_cache_name = f"models--Systran--faster-whisper-{model_size}"
model_cache_path = cache_dir / repo_cache_name
# Check if model files exist - verify essential files are present
if model_cache_path.exists():
snapshots_dir = model_cache_path / "snapshots"
if snapshots_dir.exists():
# Check for actual model files, not just any file
for snapshot in snapshots_dir.iterdir():
if snapshot.is_dir():
# Essential faster-whisper model files
required_files = ["model.bin", "config.json"]
has_all_files = all(
(snapshot / f).exists() for f in required_files
)
if has_all_files:
print(json.dumps({
"status": "model_cached",
"model": model_size,
"path": str(snapshot)
}), flush=True)
return True
# Snapshots exist but no valid model found
print(json.dumps({
"status": "incomplete_cache",
"model": model_size,
"message": "Model cache incomplete, will re-download"
}), flush=True)
# Model not cached, need to download
print(json.dumps({
"status": "downloading_model",
"model": model_size,
"repo": repo_id,
"progress": 0
}), flush=True)
try:
# Custom progress callback class
class DownloadProgressCallback:
def __init__(self):
self.total_files = 0
self.downloaded_files = 0
self.current_file_progress = 0
self.last_reported_percent = -5 # Report every 5%
def __call__(self, progress: float, total: float, filename: str = ""):
if total > 0:
percent = int((progress / total) * 100)
# Report every 5% or at completion
if percent >= self.last_reported_percent + 5 or percent == 100:
self.last_reported_percent = percent
downloaded_mb = progress / (1024 * 1024)
total_mb = total / (1024 * 1024)
print(json.dumps({
"status": "downloading_model",
"model": model_size,
"progress": percent,
"downloaded_mb": round(downloaded_mb, 1),
"total_mb": round(total_mb, 1),
"file": filename
}), flush=True)
# Use huggingface_hub to download with a simple approach
# We'll monitor the download by checking file sizes
import threading
import time
download_complete = False
download_error = None
def download_thread():
nonlocal download_complete, download_error
try:
snapshot_download(
repo_id,
local_dir=None, # Use default cache
local_dir_use_symlinks=False,
)
download_complete = True
except Exception as e:
download_error = str(e)
# Start download in background thread
thread = threading.Thread(target=download_thread)
thread.start()
# Monitor progress by checking cache directory
last_size = 0
last_report_time = time.time()
estimated_size_mb = {
"tiny": 77,
"base": 145,
"small": 488,
"medium": 1530,
"large": 3100,
"large-v2": 3100,
"large-v3": 3100,
}.get(model_size, 1530) # Default to medium size
while thread.is_alive():
time.sleep(1)
try:
# Check current download size
current_size = 0
if model_cache_path.exists():
for file in model_cache_path.rglob("*"):
if file.is_file():
current_size += file.stat().st_size
current_mb = current_size / (1024 * 1024)
progress = min(99, int((current_mb / estimated_size_mb) * 100))
# Report progress every 5 seconds or if significant change
now = time.time()
if now - last_report_time >= 5 or (current_mb - last_size / (1024 * 1024)) > 50:
if current_size > last_size:
print(json.dumps({
"status": "downloading_model",
"model": model_size,
"progress": progress,
"downloaded_mb": round(current_mb, 1),
"total_mb": estimated_size_mb
}), flush=True)
last_size = current_size
last_report_time = now
except Exception:
pass
thread.join()
if download_error:
print(json.dumps({
"status": "download_error",
"error": download_error
}), flush=True)
return False
print(json.dumps({
"status": "model_downloaded",
"model": model_size
}), flush=True)
return True
except Exception as e:
print(json.dumps({
"status": "download_error",
"error": str(e)
}), flush=True)
return False
class ChinesePunctuator:
"""Rule-based Chinese punctuation processor."""
@@ -342,17 +509,30 @@ class Transcriber:
self.vad_model: Optional[SileroVAD] = None
try:
print(json.dumps({"status": "loading_model", "model": model_size}), file=sys.stderr)
# Check if model needs to be downloaded (with progress reporting)
download_ok = check_and_download_whisper_model(model_size)
if not download_ok:
print(json.dumps({
"status": "model_error",
"error": "Failed to download model"
}), flush=True)
raise RuntimeError("Failed to download Whisper model")
# Now load the model
print(json.dumps({"status": "loading_model", "model": model_size}), flush=True)
self.model = WhisperModel(model_size, device=device, compute_type=compute_type)
self.converter = opencc.OpenCC("s2twp")
print(json.dumps({"status": "model_loaded"}), file=sys.stderr)
print(json.dumps({"status": "model_loaded", "model": model_size}), flush=True)
# Pre-load VAD model at startup (not when streaming starts)
if ONNX_AVAILABLE:
self.vad_model = SileroVAD()
except Exception as e:
print(json.dumps({"error": f"Failed to load model: {e}"}), file=sys.stderr)
print(json.dumps({
"status": "model_error",
"error": f"Failed to load model: {e}"
}), flush=True)
raise
def transcribe_file(self, audio_path: str, add_punctuation: bool = False) -> str:
@@ -683,7 +863,7 @@ class Transcriber:
def main():
model_size = os.environ.get("WHISPER_MODEL", "small")
model_size = os.environ.get("WHISPER_MODEL", "medium")
device = os.environ.get("WHISPER_DEVICE", "cpu")
compute_type = os.environ.get("WHISPER_COMPUTE", "int8")

260
start-browser.sh Executable file
View File

@@ -0,0 +1,260 @@
#!/bin/bash
#
# Meeting Assistant - Browser Mode Startup Script
# 使用瀏覽器運行 Meeting Assistant完整功能包含即時語音轉寫
#
# 此模式下:
# - 後端會自動啟動並管理 SidecarWhisper 語音轉寫引擎)
# - 前端在 Chrome/Edge 瀏覽器中運行
# - 所有功能皆可正常使用
#
set -e
# 顏色定義
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 專案路徑
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$PROJECT_DIR/backend"
SIDECAR_DIR="$PROJECT_DIR/sidecar"
# Server Configuration (can be overridden by .env)
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"
BACKEND_PORT="${BACKEND_PORT:-8000}"
# Whisper Configuration (can be overridden by .env)
export WHISPER_MODEL="${WHISPER_MODEL:-medium}"
export WHISPER_DEVICE="${WHISPER_DEVICE:-cpu}"
export WHISPER_COMPUTE="${WHISPER_COMPUTE:-int8}"
# Browser mode flag - tells backend to manage sidecar
export BROWSER_MODE="true"
# 函數:印出訊息
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[OK]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Load environment variables from .env file if it exists
if [ -f "$BACKEND_DIR/.env" ]; then
log_info "Loading backend environment from $BACKEND_DIR/.env"
export $(grep -v '^#' "$BACKEND_DIR/.env" | grep -v '^$' | xargs)
fi
# 函數:檢查 port 是否被佔用
check_port() {
local port=$1
if lsof -i :$port > /dev/null 2>&1; then
return 0 # port 被佔用
else
return 1 # port 可用
fi
}
# 函數:開啟瀏覽器
open_browser() {
local url=$1
log_info "Opening browser at $url"
# Try different browser commands
if command -v xdg-open &> /dev/null; then
xdg-open "$url" &
elif command -v wslview &> /dev/null; then
wslview "$url" &
elif command -v explorer.exe &> /dev/null; then
# WSL: use Windows browser
explorer.exe "$url" &
elif command -v open &> /dev/null; then
# macOS
open "$url" &
else
log_warn "Could not find a browser to open. Please manually visit: $url"
fi
}
# 函數:檢查環境
check_environment() {
local all_ok=true
# 檢查後端虛擬環境
if [ ! -d "$BACKEND_DIR/venv" ]; then
log_error "Backend virtual environment not found"
log_error "Please run: cd $BACKEND_DIR && python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt"
all_ok=false
fi
# 檢查 Sidecar 虛擬環境
if [ ! -d "$SIDECAR_DIR/venv" ]; then
log_warn "Sidecar virtual environment not found"
log_warn "即時語音轉寫功能將無法使用"
log_warn "To enable: cd $SIDECAR_DIR && python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt"
else
log_success "Sidecar environment found - 即時語音轉寫功能可用"
fi
if [ "$all_ok" = false ]; then
exit 1
fi
}
# 函數:啟動後端(包含 Sidecar
start_backend() {
log_info "Checking backend status..."
# Check if backend is already running
if check_port $BACKEND_PORT; then
# Verify it's our backend by checking health endpoint
if curl -s http://localhost:$BACKEND_PORT/api/health > /dev/null 2>&1; then
log_success "Backend is already running on port $BACKEND_PORT"
return 0
else
log_warn "Port $BACKEND_PORT is in use but not by our backend"
log_error "Please stop the process using port $BACKEND_PORT and try again"
exit 1
fi
fi
log_info "Starting backend server (with Sidecar management)..."
log_info "Whisper config: model=$WHISPER_MODEL, device=$WHISPER_DEVICE, compute=$WHISPER_COMPUTE"
cd "$BACKEND_DIR"
source venv/bin/activate
# Start uvicorn in background
nohup uvicorn app.main:app --host $BACKEND_HOST --port $BACKEND_PORT > "$PROJECT_DIR/backend-browser.log" 2>&1 &
local backend_pid=$!
# Wait for backend to be ready
log_info "Waiting for backend and sidecar to start..."
log_info "(This may take a minute if Whisper model needs to download)"
local max_wait=120 # 2 minutes for model download
local waited=0
while [ $waited -lt $max_wait ]; do
sleep 2
waited=$((waited + 2))
if curl -s http://localhost:$BACKEND_PORT/api/health > /dev/null 2>&1; then
log_success "Backend started (PID: $backend_pid)"
# Check sidecar status
local sidecar_status=$(curl -s http://localhost:$BACKEND_PORT/api/sidecar/status 2>/dev/null)
if echo "$sidecar_status" | grep -q '"ready":true'; then
log_success "Sidecar (Whisper) ready"
elif echo "$sidecar_status" | grep -q '"available":false'; then
log_warn "Sidecar not available - transcription disabled"
else
log_info "Sidecar loading... (model may be downloading)"
fi
return 0
fi
# Show progress every 10 seconds
if [ $((waited % 10)) -eq 0 ]; then
log_info "Still waiting... ($waited seconds)"
fi
done
log_error "Backend failed to start. Check $PROJECT_DIR/backend-browser.log for details"
exit 1
}
# 函數:停止服務
stop_services() {
log_info "Stopping services..."
pkill -f "uvicorn app.main:app" 2>/dev/null || true
sleep 1
log_success "Services stopped"
}
# 主程式
main() {
echo ""
echo "=========================================="
echo " Meeting Assistant - Browser Mode"
echo "=========================================="
echo ""
# Check environment
check_environment
# Start backend (which manages sidecar)
start_backend
# Give it a moment
sleep 1
# Open browser
local url="http://localhost:$BACKEND_PORT"
open_browser "$url"
echo ""
echo "=========================================="
log_success "Browser mode started!"
echo "=========================================="
echo ""
echo " Access URL: $url"
echo " API Docs: $url/docs"
echo ""
echo " Features:"
echo " - 即時語音轉寫(透過後端 Sidecar"
echo " - 上傳音訊轉寫"
echo " - AI 摘要"
echo " - 匯出 Excel"
echo ""
echo " To stop: $0 stop"
echo ""
log_info "Press Ctrl+C to exit (backend will keep running)"
echo ""
# Keep script running
trap 'echo ""; log_info "Exiting (backend still running)"; exit 0' INT TERM
while true; do
sleep 60
done
}
# 處理命令
case "${1:-start}" in
start)
main
;;
stop)
stop_services
;;
restart)
stop_services
sleep 2
main
;;
status)
if check_port $BACKEND_PORT; then
log_success "Backend running on port $BACKEND_PORT"
curl -s http://localhost:$BACKEND_PORT/api/sidecar/status | python3 -m json.tool 2>/dev/null || echo "(Could not parse sidecar status)"
else
log_warn "Backend not running"
fi
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac