Compare commits
35 Commits
92e203422b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a2efb3b9b | ||
|
|
9da6c91dbe | ||
|
|
e68c5ebd9f | ||
|
|
fd203ef771 | ||
|
|
771655e03e | ||
|
|
7d3fc72bd2 | ||
|
|
e7a06e2b8f | ||
|
|
c36f4167f2 | ||
|
|
6112799c79 | ||
|
|
9a6ca5730b | ||
|
|
c05fdad8e4 | ||
|
|
0defc829dd | ||
|
|
a9bd6b34f1 | ||
|
|
56cf0c072c | ||
|
|
08cffbb74a | ||
|
|
bc37a5392a | ||
|
|
6f09c5f7cc | ||
|
|
d75789f23e | ||
|
|
012cdaf5f3 | ||
|
|
66295f5177 | ||
|
|
49dba2c43e | ||
|
|
e565951bf6 | ||
|
|
744cfd1d27 | ||
|
|
81905688a6 | ||
|
|
01d578c67e | ||
|
|
34947f6262 | ||
|
|
ac2d9a0240 | ||
|
|
2f80a4ac76 | ||
|
|
6066946096 | ||
|
|
c9dc5839db | ||
|
|
58f379bc0c | ||
|
|
b1633fdcff | ||
|
|
3dd667197f | ||
|
|
d3e3205692 | ||
|
|
7075078d9e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ backend/record/
|
||||
openspec/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
.running_pids
|
||||
|
||||
349
DEPLOYMENT.md
349
DEPLOYMENT.md
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
346
backend/app/routers/sidecar.py
Normal file
346
backend/app/routers/sidecar.py
Normal 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()
|
||||
343
backend/app/sidecar_manager.py
Normal file
343
backend/app/sidecar_manager.py
Normal 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
144
backend/build.py
Normal 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()
|
||||
@@ -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
165
backend/run_server.py
Normal 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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
339
client/src/services/browser-api.js
Normal file
339
client/src/services/browser-api.js
Normal 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 提供即時語音轉寫功能');
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 給使用者
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: 前端開發團隊
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
133
openspec/specs/audio-device-management/spec.md
Normal file
133
openspec/specs/audio-device-management/spec.md
Normal 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
|
||||
|
||||
130
openspec/specs/embedded-backend/spec.md
Normal file
130
openspec/specs/embedded-backend/spec.md
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: 產生免安裝 exe,SQLite 資料庫儲存到 %%APPDATA%%
|
||||
echo.
|
||||
echo 資料庫模式 - 全包部署時:
|
||||
echo SQLite 預設: 本地資料庫,完全離線運作
|
||||
echo MySQL: 需明確指定 --database-type mysql,連接雲端資料庫
|
||||
echo.
|
||||
echo 注意:
|
||||
echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間
|
||||
echo - 全包部署需要額外約 50MB 空間用於後端
|
||||
echo - 確保有足夠的磁碟空間 (建議 5GB+)
|
||||
echo.
|
||||
goto :eof
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
260
start-browser.sh
Executable file
@@ -0,0 +1,260 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Meeting Assistant - Browser Mode Startup Script
|
||||
# 使用瀏覽器運行 Meeting Assistant(完整功能,包含即時語音轉寫)
|
||||
#
|
||||
# 此模式下:
|
||||
# - 後端會自動啟動並管理 Sidecar(Whisper 語音轉寫引擎)
|
||||
# - 前端在 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
|
||||
Reference in New Issue
Block a user