From 7d4fc69071be6ad10956408687a9aed226851525 Mon Sep 17 00:00:00 2001 From: egg Date: Tue, 16 Dec 2025 20:03:16 +0800 Subject: [PATCH] feat: Add build scripts and runtime config support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add setup-backend.sh/bat for one-click backend setup - Fix test_auth.py mock settings (JWT_EXPIRE_HOURS) - Fix test_excel_export.py TEMPLATE_DIR reference Frontend: - Add config.json for runtime API URL configuration - Add init.js and settings.js for config loading - Update main.js to load config from external file - Update api.js to use dynamic API_BASE_URL - Update all pages to initialize config before API calls - Update package.json with extraResources for config Build: - Add build-client.sh/bat for packaging Electron + Sidecar - Add build-all.ps1 PowerShell script with -ApiUrl parameter - Add GitHub Actions workflow for Windows builds - Add scripts/README.md documentation This allows IT to configure backend URL without rebuilding. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/build-windows.yml | 155 +++++++++ backend/tests/test_auth.py | 7 +- backend/tests/test_excel_export.py | 6 +- client/config.json | 10 + client/package.json | 35 +- client/src/config/settings.js | 116 +++++++ client/src/main.js | 114 ++++++- client/src/pages/login.html | 4 + client/src/pages/meeting-detail.html | 4 + client/src/pages/meetings.html | 4 + client/src/preload.js | 3 + client/src/services/api.js | 33 +- client/src/services/init.js | 39 +++ scripts/README.md | 199 +++++++++++ scripts/build-all.ps1 | 304 +++++++++++++++++ scripts/build-client.bat | 324 ++++++++++++++++++ scripts/build-client.sh | 473 +++++++++++++++++++++++++++ scripts/setup-backend.bat | 237 ++++++++++++++ scripts/setup-backend.sh | 393 ++++++++++++++++++++++ start.sh | 26 +- 20 files changed, 2454 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/build-windows.yml create mode 100644 client/config.json create mode 100644 client/src/config/settings.js create mode 100644 client/src/services/init.js create mode 100644 scripts/README.md create mode 100644 scripts/build-all.ps1 create mode 100644 scripts/build-client.bat create mode 100755 scripts/build-client.sh create mode 100644 scripts/setup-backend.bat create mode 100755 scripts/setup-backend.sh diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..4955fa2 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,155 @@ +name: Build Windows Client + +on: + workflow_dispatch: # 手動觸發 + inputs: + api_url: + description: 'Backend API URL (e.g., http://192.168.1.100:8000/api)' + required: false + default: 'http://localhost:8000/api' + type: string + whisper_model: + description: 'Whisper model size' + required: false + default: 'medium' + type: choice + options: + - tiny + - base + - small + - medium + - large + push: + tags: + - 'v*' # 推送版本 tag 時自動觸發 + +jobs: + build-sidecar: + name: Build Sidecar (Python → exe) + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: sidecar/requirements.txt + + - name: Install sidecar dependencies + working-directory: sidecar + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build sidecar with PyInstaller + working-directory: sidecar + run: | + pyinstaller ` + --onedir ` + --name transcriber ` + --distpath dist ` + --workpath build ` + --noconfirm ` + --clean ` + --console ` + --hidden-import=faster_whisper ` + --hidden-import=ctranslate2 ` + --hidden-import=huggingface_hub ` + --hidden-import=tokenizers ` + --hidden-import=onnxruntime ` + --hidden-import=opencc ` + --hidden-import=pydub ` + --hidden-import=numpy ` + --hidden-import=av ` + --collect-data=onnxruntime ` + --collect-data=faster_whisper ` + transcriber.py + + - name: Upload sidecar artifact + uses: actions/upload-artifact@v4 + with: + name: sidecar-windows + path: sidecar/dist/transcriber/ + retention-days: 1 + + build-electron: + name: Build Electron App + needs: build-sidecar + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: client/package-lock.json + + - name: Download sidecar artifact + uses: actions/download-artifact@v4 + with: + name: sidecar-windows + path: sidecar/dist/transcriber/ + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Update config.json with API URL + working-directory: client + shell: pwsh + run: | + $configPath = "config.json" + $apiUrl = "${{ inputs.api_url || 'http://localhost:8000/api' }}" + + if (Test-Path $configPath) { + $config = Get-Content $configPath -Raw | ConvertFrom-Json + $config.apiBaseUrl = $apiUrl + $config | ConvertTo-Json -Depth 10 | Set-Content $configPath -Encoding UTF8 + Write-Host "Updated API URL to: $apiUrl" + } + + - name: Build Electron app + working-directory: client + run: npm run build -- --win + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Windows build + uses: actions/upload-artifact@v4 + with: + name: meeting-assistant-windows + path: | + client/dist/*.exe + client/dist/*.zip + retention-days: 30 + + release: + name: Create Release + needs: build-electron + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Download Windows build + uses: actions/download-artifact@v4 + with: + name: meeting-assistant-windows + path: dist/ + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: dist/* + draft: true + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 38e0bef..de51ef2 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -29,6 +29,7 @@ class TestAdminRoleDetection: def test_create_token_includes_role(self, mock_settings): """Test that created tokens include the role.""" mock_settings.JWT_SECRET = "test-secret" + mock_settings.JWT_EXPIRE_HOURS = 24 mock_settings.ADMIN_EMAIL = "admin@test.com" from app.routers.auth import create_token @@ -51,6 +52,7 @@ class TestTokenValidation: def test_decode_valid_token(self, mock_settings): """Test decoding a valid token.""" mock_settings.JWT_SECRET = "test-secret" + mock_settings.JWT_EXPIRE_HOURS = 24 from app.routers.auth import create_token, decode_token @@ -93,10 +95,11 @@ class TestLoginEndpoint: mock_settings.AUTH_API_URL = "https://auth.test.com/login" mock_settings.ADMIN_EMAIL = "admin@test.com" mock_settings.JWT_SECRET = "test-secret" + mock_settings.JWT_EXPIRE_HOURS = 24 mock_response = MagicMock() mock_response.status_code = 200 - mock_response.json.return_value = {"token": "external-token"} + mock_response.json.return_value = {"token": "external-token", "success": True} mock_client = AsyncMock() mock_client.post.return_value = mock_response @@ -120,9 +123,11 @@ class TestLoginEndpoint: mock_settings.AUTH_API_URL = "https://auth.test.com/login" mock_settings.ADMIN_EMAIL = "admin@test.com" mock_settings.JWT_SECRET = "test-secret" + mock_settings.JWT_EXPIRE_HOURS = 24 mock_response = MagicMock() mock_response.status_code = 200 + mock_response.json.return_value = {"token": "external-token", "success": True} mock_client = AsyncMock() mock_client.post.return_value = mock_response diff --git a/backend/tests/test_excel_export.py b/backend/tests/test_excel_export.py index 7117d20..fdd2af3 100644 --- a/backend/tests/test_excel_export.py +++ b/backend/tests/test_excel_export.py @@ -7,7 +7,11 @@ from openpyxl import load_workbook # Add parent directory to path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from app.routers.export import fill_template_workbook, create_default_workbook, TEMPLATE_DIR, RECORD_DIR +from app.routers.export import fill_template_workbook, create_default_workbook, BASE_DIR +from app.config import settings + +TEMPLATE_DIR = settings.get_template_dir(BASE_DIR) +RECORD_DIR = settings.get_record_dir(BASE_DIR) def test_excel_export(): diff --git a/client/config.json b/client/config.json new file mode 100644 index 0000000..aab5be3 --- /dev/null +++ b/client/config.json @@ -0,0 +1,10 @@ +{ + "apiBaseUrl": "http://localhost:8000/api", + "uploadTimeout": 600000, + "appTitle": "Meeting Assistant", + "whisper": { + "model": "medium", + "device": "cpu", + "compute": "int8" + } +} diff --git a/client/package.json b/client/package.json index 970bb01..354819c 100644 --- a/client/package.json +++ b/client/package.json @@ -29,19 +29,44 @@ ], "extraResources": [ { - "from": "../sidecar/dist", - "to": "sidecar", + "from": "../sidecar/dist/transcriber", + "to": "sidecar/transcriber", "filter": ["**/*"] + }, + { + "from": "config.json", + "to": "config.json" } ], "win": { - "target": "portable" + "target": [ + { + "target": "portable", + "arch": ["x64"] + } + ], + "icon": "assets/icon.ico" }, "mac": { - "target": "dmg" + "target": [ + { + "target": "dmg", + "arch": ["x64", "arm64"] + } + ], + "icon": "assets/icon.icns" }, "linux": { - "target": "AppImage" + "target": [ + { + "target": "AppImage", + "arch": ["x64"] + } + ], + "icon": "assets/icon.png" + }, + "portable": { + "artifactName": "${productName}-${version}-portable.${ext}" } } } diff --git a/client/src/config/settings.js b/client/src/config/settings.js new file mode 100644 index 0000000..7ef8ab5 --- /dev/null +++ b/client/src/config/settings.js @@ -0,0 +1,116 @@ +/** + * Runtime Configuration Module + * + * Loads settings from external config.json file at runtime, + * allowing IT to configure the app without rebuilding. + * + * Config file location: + * - Development: client/config.json + * - Packaged app: /resources/config.json + */ + +// Default settings (used if config.json is not found) +const DEFAULT_SETTINGS = { + apiBaseUrl: "http://localhost:8000/api", + uploadTimeout: 600000, + appTitle: "Meeting Assistant", + whisper: { + model: "medium", + device: "cpu", + compute: "int8" + } +}; + +let _settings = null; +let _configPath = null; + +/** + * Get the config file path based on environment + */ +function getConfigPath() { + if (_configPath) return _configPath; + + // Check if running in Electron + if (typeof window !== "undefined" && window.electronAPI) { + // Will be set by preload.js + return null; + } + + // Browser/development fallback + return "./config.json"; +} + +/** + * Load settings from config file + * Called once at app startup + */ +export async function loadSettings() { + if (_settings) return _settings; + + try { + // Try to load from Electron's exposed config + if (typeof window !== "undefined" && window.electronAPI?.getConfig) { + _settings = await window.electronAPI.getConfig(); + console.log("Settings loaded from Electron config:", _settings); + return _settings; + } + + // Fallback: try to fetch config.json + const configPath = getConfigPath(); + if (configPath) { + const response = await fetch(configPath); + if (response.ok) { + const config = await response.json(); + _settings = { ...DEFAULT_SETTINGS, ...config }; + console.log("Settings loaded from config.json:", _settings); + return _settings; + } + } + } catch (error) { + console.warn("Failed to load config.json, using defaults:", error.message); + } + + // Use defaults + _settings = { ...DEFAULT_SETTINGS }; + console.log("Using default settings:", _settings); + return _settings; +} + +/** + * Get current settings (must call loadSettings first) + */ +export function getSettings() { + if (!_settings) { + console.warn("Settings not loaded yet, returning defaults"); + return { ...DEFAULT_SETTINGS }; + } + return _settings; +} + +/** + * Get API base URL + */ +export function getApiBaseUrl() { + return getSettings().apiBaseUrl; +} + +/** + * Get upload timeout + */ +export function getUploadTimeout() { + return getSettings().uploadTimeout; +} + +/** + * Get app title + */ +export function getAppTitle() { + return getSettings().appTitle; +} + +/** + * Get Whisper configuration + */ +export function getWhisperConfig() { + return getSettings().whisper; +} diff --git a/client/src/main.js b/client/src/main.js index 26fb0d3..2e6ed30 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -8,11 +8,61 @@ let mainWindow; let sidecarProcess; let sidecarReady = false; let streamingActive = false; +let appConfig = null; + +/** + * Load configuration from external config.json + * Config file location: + * - Development: client/config.json + * - Packaged: /resources/config.json + */ +function loadConfig() { + const configPaths = [ + // Packaged app: resources folder + app.isPackaged ? path.join(process.resourcesPath, "config.json") : null, + // Development: client folder + path.join(__dirname, "..", "config.json"), + // Fallback: same directory + path.join(__dirname, "config.json"), + ].filter(Boolean); + + for (const configPath of configPaths) { + try { + if (fs.existsSync(configPath)) { + const configData = fs.readFileSync(configPath, "utf-8"); + appConfig = JSON.parse(configData); + console.log("Config loaded from:", configPath); + console.log("Config:", appConfig); + return appConfig; + } + } catch (error) { + console.warn("Failed to load config from:", configPath, error.message); + } + } + + // Default configuration + appConfig = { + apiBaseUrl: "http://localhost:8000/api", + uploadTimeout: 600000, + appTitle: "Meeting Assistant", + whisper: { + model: "medium", + device: "cpu", + compute: "int8" + } + }; + console.log("Using default config:", appConfig); + return appConfig; +} function createWindow() { + // Set window title from config + const windowTitle = appConfig?.appTitle || "Meeting Assistant"; + mainWindow = new BrowserWindow({ width: 1200, height: 800, + title: windowTitle, webPreferences: { nodeIntegration: false, contextIsolation: true, @@ -32,34 +82,65 @@ function startSidecar() { ? path.join(process.resourcesPath, "sidecar") : path.join(__dirname, "..", "..", "sidecar"); - const sidecarScript = path.join(sidecarDir, "transcriber.py"); - const venvPython = path.join(sidecarDir, "venv", "bin", "python"); + // Determine the sidecar executable path based on packaging and platform + let sidecarExecutable; + let sidecarArgs = []; - if (!fs.existsSync(sidecarScript)) { - console.log("Sidecar script not found at:", sidecarScript); + if (app.isPackaged) { + // Packaged app: use PyInstaller-built executable + if (process.platform === "win32") { + sidecarExecutable = path.join(sidecarDir, "transcriber", "transcriber.exe"); + } else if (process.platform === "darwin") { + sidecarExecutable = path.join(sidecarDir, "transcriber", "transcriber"); + } else { + sidecarExecutable = path.join(sidecarDir, "transcriber", "transcriber"); + } + } else { + // Development mode: use Python script with venv + const sidecarScript = path.join(sidecarDir, "transcriber.py"); + + if (!fs.existsSync(sidecarScript)) { + console.log("Sidecar script not found at:", sidecarScript); + console.log("Transcription will not be available."); + return; + } + + // Check for virtual environment Python + let venvPython; + if (process.platform === "win32") { + venvPython = path.join(sidecarDir, "venv", "Scripts", "python.exe"); + } else { + venvPython = path.join(sidecarDir, "venv", "bin", "python"); + } + + sidecarExecutable = fs.existsSync(venvPython) ? venvPython : "python3"; + sidecarArgs = [sidecarScript]; + } + + if (!fs.existsSync(sidecarExecutable)) { + console.log("Sidecar executable not found at:", sidecarExecutable); console.log("Transcription will not be available."); return; } - const pythonPath = fs.existsSync(venvPython) ? venvPython : "python3"; - try { - // Get Whisper configuration from environment variables + // Get Whisper configuration from config.json or environment variables + const whisperConfig = appConfig?.whisper || {}; const whisperEnv = { ...process.env, - WHISPER_MODEL: process.env.WHISPER_MODEL || "medium", - WHISPER_DEVICE: process.env.WHISPER_DEVICE || "cpu", - WHISPER_COMPUTE: process.env.WHISPER_COMPUTE || "int8", + WHISPER_MODEL: process.env.WHISPER_MODEL || whisperConfig.model || "medium", + WHISPER_DEVICE: process.env.WHISPER_DEVICE || whisperConfig.device || "cpu", + WHISPER_COMPUTE: process.env.WHISPER_COMPUTE || whisperConfig.compute || "int8", }; - console.log("Starting sidecar with:", pythonPath, sidecarScript); + console.log("Starting sidecar with:", sidecarExecutable, sidecarArgs.join(" ")); console.log("Whisper config:", { model: whisperEnv.WHISPER_MODEL, device: whisperEnv.WHISPER_DEVICE, compute: whisperEnv.WHISPER_COMPUTE, }); - sidecarProcess = spawn(pythonPath, [sidecarScript], { + sidecarProcess = spawn(sidecarExecutable, sidecarArgs, { cwd: sidecarDir, stdio: ["pipe", "pipe", "pipe"], env: whisperEnv, @@ -121,6 +202,9 @@ function startSidecar() { } app.whenReady().then(() => { + // Load configuration first + loadConfig(); + createWindow(); startSidecar(); @@ -144,6 +228,12 @@ app.on("window-all-closed", () => { }); // IPC handlers + +// Get app configuration (for renderer to initialize API settings) +ipcMain.handle("get-config", () => { + return appConfig; +}); + ipcMain.handle("navigate", (event, page) => { mainWindow.loadFile(path.join(__dirname, "pages", `${page}.html`)); }); diff --git a/client/src/pages/login.html b/client/src/pages/login.html index 99c6666..fbafc0b 100644 --- a/client/src/pages/login.html +++ b/client/src/pages/login.html @@ -26,8 +26,12 @@