diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..ae946e4 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,150 @@ +name: Build Windows App + +on: + push: + branches: + - main + - 'feature/**' + paths-ignore: + - '.github/**' + - '!.github/workflows/build-windows.yml' + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build Windows App + runs-on: windows-latest + strategy: + matrix: + arch: [x64] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, xml, ctype, iconv, intl, pdo_sqlite, dom, filter, gd, json, mbstring, pdo + tools: composer:v2 + coverage: none + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Get version + id: version + shell: bash + run: | + VERSION=$(grep "'version' =>" config/nativephp.php | sed -E "s/.*'([0-9]+\.[0-9]+\.[0-9]+)'.*/\1/") + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Building version: $VERSION" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install Composer dependencies + run: composer install --no-interaction --no-dev --prefer-dist --optimize-autoloader + + - name: Clean npm cache and node_modules + shell: cmd + run: | + if exist node_modules rmdir /s /q node_modules + if exist package-lock.json del package-lock.json + npm cache clean --force + + - name: Install NPM dependencies (Windows-specific) + shell: cmd + run: | + echo Installing npm dependencies for Windows... + npm install --omit=dev --no-optional + echo Dependencies installed successfully + + - name: Copy .env file + run: cp .env.example .env + + - name: Generate application key + run: php artisan key:generate + + - name: Build frontend assets + run: npm run build + + - name: Generate Ziggy routes + run: php artisan ziggy:generate + + - name: Install Electron dependencies + working-directory: vendor/nativephp/electron/resources/js + shell: cmd + run: | + if exist node_modules rmdir /s /q node_modules + npm install --no-optional + + - name: Rebuild Electron native modules + working-directory: vendor/nativephp/electron/resources/js + run: npx electron-rebuild + + - name: Build NativePHP application + env: + NATIVEPHP_APP_VERSION: ${{ steps.version.outputs.VERSION }} + run: | + php artisan native:build win ${{ matrix.arch }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: Clueless-${{ steps.version.outputs.VERSION }}-win-${{ matrix.arch }} + path: dist/*.exe + retention-days: 5 + + release: + name: Create Windows Release + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: version + run: | + VERSION=$(grep "'version' =>" config/nativephp.php | sed -E "s/.*'([0-9]+\.[0-9]+\.[0-9]+)'.*/\1/") + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Download x64 artifact + uses: actions/download-artifact@v4 + with: + name: Clueless-${{ steps.version.outputs.VERSION }}-win-x64 + path: ./artifacts/win-x64 + + - name: Upload Windows Release Assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Find the existing release for v${{ steps.version.outputs.VERSION }} + RELEASE_ID=$(gh api repos/${{ github.repository }}/releases/tags/v${{ steps.version.outputs.VERSION }} --jq '.id' || echo "") + + if [ -z "$RELEASE_ID" ]; then + echo "No release found for v${{ steps.version.outputs.VERSION }}. Windows build will be added when release is created." + exit 0 + fi + + # Upload the Windows executables + gh release upload v${{ steps.version.outputs.VERSION }} \ + ./artifacts/win-x64/Clueless-${{ steps.version.outputs.VERSION }}-win-x64.exe \ + --clobber \ No newline at end of file diff --git a/app/Http/Controllers/ConversationController.php b/app/Http/Controllers/ConversationController.php index 921f100..fa60db2 100644 --- a/app/Http/Controllers/ConversationController.php +++ b/app/Http/Controllers/ConversationController.php @@ -282,4 +282,44 @@ public function destroy(ConversationSession $session) return redirect()->route('conversations.index') ->with('message', 'Conversation deleted successfully'); } + + /** + * Update recording information for a conversation session. + */ + public function updateRecording(ConversationSession $session, Request $request) + { + // No auth check needed for single-user desktop app + + $validated = $request->validate([ + 'has_recording' => 'required|boolean', + 'recording_path' => 'required|string', + 'recording_duration' => 'required|integer|min:0', + 'recording_size' => 'required|integer|min:0', + ]); + + // Validate that the recording file actually exists + if ($validated['has_recording'] && $validated['recording_path']) { + if (!file_exists($validated['recording_path'])) { + return response()->json([ + 'message' => 'Recording file not found at specified path', + ], 422); + } + + // Verify file size matches + $actualSize = filesize($validated['recording_path']); + if ($actualSize !== $validated['recording_size']) { + \Log::warning('Recording file size mismatch', [ + 'session_id' => $session->id, + 'expected_size' => $validated['recording_size'], + 'actual_size' => $actualSize, + ]); + } + } + + $session->update($validated); + + return response()->json([ + 'message' => 'Recording information updated successfully', + ]); + } } diff --git a/app/Models/ConversationSession.php b/app/Models/ConversationSession.php index d1bbea0..53d4178 100644 --- a/app/Models/ConversationSession.php +++ b/app/Models/ConversationSession.php @@ -31,6 +31,10 @@ class ConversationSession extends Model 'total_action_items', 'ai_summary', 'user_notes', + 'has_recording', + 'recording_path', + 'recording_duration', + 'recording_size', ]; protected $casts = [ @@ -42,6 +46,9 @@ class ConversationSession extends Model 'total_topics' => 'integer', 'total_commitments' => 'integer', 'total_action_items' => 'integer', + 'has_recording' => 'boolean', + 'recording_duration' => 'integer', + 'recording_size' => 'integer', ]; public function user(): BelongsTo diff --git a/database/migrations/2025_08_05_214155_add_recording_fields_to_conversation_sessions_table.php b/database/migrations/2025_08_05_214155_add_recording_fields_to_conversation_sessions_table.php new file mode 100644 index 0000000..cf81f93 --- /dev/null +++ b/database/migrations/2025_08_05_214155_add_recording_fields_to_conversation_sessions_table.php @@ -0,0 +1,31 @@ +boolean('has_recording')->default(false)->after('ended_at'); + $table->string('recording_path')->nullable()->after('has_recording'); + $table->integer('recording_duration')->nullable()->comment('Duration in seconds')->after('recording_path'); + $table->bigInteger('recording_size')->nullable()->comment('Size in bytes')->after('recording_duration'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('conversation_sessions', function (Blueprint $table) { + $table->dropColumn(['has_recording', 'recording_path', 'recording_duration', 'recording_size']); + }); + } +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8f77284..8178273 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,8 +50,7 @@ "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", - "lightningcss-linux-x64-gnu": "^1.29.1", - "node-mac-permissions": "^2.5.0" + "lightningcss-linux-x64-gnu": "^1.29.1" } }, "node_modules/@ampproject/remapping": { @@ -1779,17 +1778,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", - "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz", + "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/type-utils": "8.38.0", - "@typescript-eslint/utils": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/type-utils": "8.39.0", + "@typescript-eslint/utils": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1803,9 +1802,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.38.0", + "@typescript-eslint/parser": "^8.39.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1819,16 +1818,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", - "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz", + "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4" }, "engines": { @@ -1840,18 +1839,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", - "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz", + "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.38.0", - "@typescript-eslint/types": "^8.38.0", + "@typescript-eslint/tsconfig-utils": "^8.39.0", + "@typescript-eslint/types": "^8.39.0", "debug": "^4.3.4" }, "engines": { @@ -1862,18 +1861,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", - "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz", + "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0" + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1884,9 +1883,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", - "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz", + "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", "dev": true, "license": "MIT", "engines": { @@ -1897,19 +1896,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", - "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz", + "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/utils": "8.39.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1922,13 +1921,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", - "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz", + "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==", "dev": true, "license": "MIT", "engines": { @@ -1940,16 +1939,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", - "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz", + "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.38.0", - "@typescript-eslint/tsconfig-utils": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/project-service": "8.39.0", + "@typescript-eslint/tsconfig-utils": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1965,20 +1964,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", - "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz", + "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0" + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1989,17 +1988,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", - "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz", + "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/types": "8.39.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2424,16 +2423,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/birpc": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.5.0.tgz", @@ -3054,9 +3043,9 @@ } }, "node_modules/electron-audio-loopback": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/electron-audio-loopback/-/electron-audio-loopback-1.0.5.tgz", - "integrity": "sha512-E/xneHrk2tLD7JntbjBJJr4HyWGaLrdGvqmoeBJZg9URbBBm2OgTEZ5TWgTkIPiAwEvAllsV+VfdBOfP4FeMkw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/electron-audio-loopback/-/electron-audio-loopback-1.0.6.tgz", + "integrity": "sha512-QW0ogDqMpWHDAQHmQyssJ+Yh4qR3kWCP3Q4H9WuIXKwVlgkqOYGyt0v/JzbK3tBNTwfqbuHZy86kwCCajxqAdg==", "license": "MIT", "peerDependencies": { "electron": ">=31.0.1" @@ -3792,13 +3781,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "optional": true - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4808,9 +4790,9 @@ } }, "node_modules/marked": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.1.1.tgz", - "integrity": "sha512-ij/2lXfCRT71L6u0M29tJPhP0bM5shLL3u5BePhFwPELj2blMJ6GDtD7PfJhRLhJ/c2UwrK17ySVcDzy2YHjHQ==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.1.2.tgz", + "integrity": "sha512-rNQt5EvRinalby7zJZu/mB+BvaAY2oz3wCuCjt1RDrWNpS1Pdf9xqMOeC9Hm5adBdcV/3XZPJpG58eT+WBc0XQ==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -5028,28 +5010,6 @@ "node": ">= 0.6" } }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT", - "optional": true - }, - "node_modules/node-mac-permissions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/node-mac-permissions/-/node-mac-permissions-2.5.0.tgz", - "integrity": "sha512-zR8SVCaN3WqV1xwWd04XVAdzm3UTdjbxciLrZtB0Cc7F2Kd34AJfhPD4hm1HU0YH3oGUZO4X9OBLY5ijSTHsGw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^7.1.0" - } - }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -5138,9 +5098,9 @@ } }, "node_modules/openai": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-5.11.0.tgz", - "integrity": "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.0.tgz", + "integrity": "sha512-vUdt02xiWgOHiYUmW0Hj1Qu9OKAiVQu5Bd547ktVCiMKC1BkB5L3ImeEnCyq3WpRKR6ZTaPgekzqdozwdPs7Lg==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -6357,9 +6317,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6370,16 +6330,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", - "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.0.tgz", + "integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.38.0", - "@typescript-eslint/parser": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0" + "@typescript-eslint/eslint-plugin": "8.39.0", + "@typescript-eslint/parser": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/utils": "8.39.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6390,7 +6350,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index f36fb27..897db95 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", - "lightningcss-linux-x64-gnu": "^1.29.1", - "node-mac-permissions": "^2.5.0" + "lightningcss-linux-x64-gnu": "^1.29.1" } } diff --git a/resources/js/components/AudioPlayer.vue b/resources/js/components/AudioPlayer.vue new file mode 100644 index 0000000..4d0d969 --- /dev/null +++ b/resources/js/components/AudioPlayer.vue @@ -0,0 +1,366 @@ + + + + + \ No newline at end of file diff --git a/resources/js/components/RealtimeAgent/Navigation/MobileMenu.vue b/resources/js/components/RealtimeAgent/Navigation/MobileMenu.vue index 195c7c0..6388442 100644 --- a/resources/js/components/RealtimeAgent/Navigation/MobileMenu.vue +++ b/resources/js/components/RealtimeAgent/Navigation/MobileMenu.vue @@ -115,6 +115,7 @@ + + \ No newline at end of file diff --git a/resources/js/components/ui/switch/Switch.vue b/resources/js/components/ui/switch/Switch.vue new file mode 100644 index 0000000..38b2028 --- /dev/null +++ b/resources/js/components/ui/switch/Switch.vue @@ -0,0 +1,30 @@ + + + \ No newline at end of file diff --git a/resources/js/components/ui/switch/index.ts b/resources/js/components/ui/switch/index.ts new file mode 100644 index 0000000..6c60fd0 --- /dev/null +++ b/resources/js/components/ui/switch/index.ts @@ -0,0 +1 @@ +export { default as Switch } from './Switch.vue'; \ No newline at end of file diff --git a/resources/js/layouts/settings/Layout.vue b/resources/js/layouts/settings/Layout.vue index 08f68a6..bec0710 100644 --- a/resources/js/layouts/settings/Layout.vue +++ b/resources/js/layouts/settings/Layout.vue @@ -14,6 +14,10 @@ const sidebarNavItems: NavItem[] = [ title: 'Appearance', href: '/settings/appearance', }, + { + title: 'Recording', + href: '/settings/recording', + }, ]; const page = usePage(); diff --git a/resources/js/nativephp-extension.js b/resources/js/nativephp-extension.js deleted file mode 100644 index 3e9e89b..0000000 --- a/resources/js/nativephp-extension.js +++ /dev/null @@ -1,274 +0,0 @@ -// NativePHP Extension for Audio Loopback Support -// This extension provides system audio capture functionality for Electron apps - -import { systemPreferences, ipcMain, shell } from 'electron'; - - -// Store audio loopback state -const audioLoopbackState = { - initialized: false, - handlerSet: false -}; - -export default { - // Hook into Electron lifecycle - called before app is ready - beforeReady: async () => { - console.log('[Extension] beforeReady hook called'); - - // Method 1: Try to use electron-audio-loopback package if available - try { - const audioLoopbackModule = await import('electron-audio-loopback'); - const initAudioLoopback = audioLoopbackModule.initMain; - - if (initAudioLoopback) { - console.log('[Extension] Initializing electron-audio-loopback package...'); - initAudioLoopback(); - audioLoopbackState.initialized = true; - console.log('[Extension] electron-audio-loopback initialized successfully'); - - // The package should register its own IPC handlers, but let's verify - setTimeout(() => { - const hasEnableHandler = ipcMain.listenerCount('enable-loopback-audio') > 0; - const hasDisableHandler = ipcMain.listenerCount('disable-loopback-audio') > 0; - console.log('[Extension] IPC handler check:', { - 'enable-loopback-audio': hasEnableHandler, - 'disable-loopback-audio': hasDisableHandler - }); - }, 100); - - return; // Package handles everything, no need for manual implementation - } - } catch (error) { - console.log('[Extension] electron-audio-loopback not available:', error.message); - console.log('[Extension] Falling back to manual implementation...'); - } - }, - - // Hook into Electron lifecycle - called after app is ready - afterReady: async (app, mainWindow) => { - console.log('[Extension Test] afterReady hook called', { - hasApp: !!app, - hasMainWindow: !!mainWindow - }); - // Add any setup that needs to happen after app is ready - }, - - // Hook into Electron lifecycle - called before app quits - beforeQuit: async () => { - console.log('[Extension Test] beforeQuit hook called'); - // Add any cleanup that needs to happen before app quits - }, - - // Custom IPC handlers for renderer communication - ipcHandlers: { - // Audio loopback handlers are now provided by the electron-audio-loopback package - // We don't need to register them here to avoid conflicts - - // Microphone permission handlers - 'check-microphone-permission': async () => { - if (process.platform !== 'darwin') { - return { status: 'authorized' }; - } - - try { - const permissions = await import('node-mac-permissions'); - const status = permissions.default.getAuthStatus('microphone'); - return { status }; - } catch (error) { - console.error('[Extension] Error checking microphone permission:', error); - return { status: 'not-determined', error: error.message }; - } - }, - - 'request-microphone-permission': async () => { - if (process.platform !== 'darwin') { - return { granted: true }; - } - - try { - const permissions = await import('node-mac-permissions'); - const status = await permissions.default.askForMicrophoneAccess(); - return { granted: status === 'authorized' }; - } catch (error) { - console.error('[Extension] Error requesting microphone permission:', error); - return { granted: false, error: error.message }; - } - }, - - // Screen capture permission handlers - 'check-screen-capture-permission': async () => { - if (process.platform !== 'darwin') { - return { status: 'authorized' }; - } - - try { - const permissions = await import('node-mac-permissions'); - const status = permissions.default.getAuthStatus('screen'); - return { status }; - } catch (error) { - console.error('[Extension] Error checking screen capture permission:', error); - return { status: 'not-determined', error: error.message }; - } - }, - - 'request-screen-capture-permission': async () => { - if (process.platform !== 'darwin') { - return { granted: true }; - } - - try { - const permissions = await import('node-mac-permissions'); - // askForScreenCaptureAccess returns a boolean directly - const granted = await permissions.default.askForScreenCaptureAccess(); - return { granted }; - } catch (error) { - console.error('[Extension] Error requesting screen capture permission:', error); - return { granted: false, error: error.message }; - } - }, - - // Open privacy settings handler - 'open-privacy-settings': async () => { - console.log('[Extension] IPC handler open-privacy-settings called'); - - if (process.platform !== 'darwin') { - return { success: true }; - } - - try { - await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy'); - return { success: true }; - } catch (error) { - console.error('[Extension] Error opening privacy settings:', error); - return { success: false, error: error.message }; - } - }, - - // Test handler to verify IPC is working - 'test:ping': async (_event, ...args) => { - console.log('[Extension Test] IPC handler test:ping called', { args }); - return { - success: true, - message: 'Pong! Extension IPC handlers are working!', - timestamp: new Date().toISOString(), - receivedArgs: args - }; - }, - - 'test:echo': async (_event, message) => { - console.log('[Extension Test] IPC handler test:echo called', { message }); - return { - success: true, - echo: message, - processedAt: new Date().toISOString() - }; - }, - - 'test:get-info': async () => { - console.log('[Extension Test] IPC handler test:get-info called'); - return { - success: true, - info: { - extensionVersion: '1.0.0', - nodeVersion: process.version, - platform: process.platform, - arch: process.arch - } - }; - } - }, - - // Preload script extensions - APIs to expose to the renderer - // NOTE: The NativePHP preload already exposes window.audioLoopback, so we don't need to do it here - // The frontend should use window.Native.ipcRendererInvoke['enable-loopback-audio']() which is already available - - // Custom API endpoints accessible from Laravel - apiRoutes: (router) => { - // Screen recording permission check for macOS - router.get('/api/system/screen-recording-access', (req, res) => { - console.log('[Extension] API route GET /api/system/screen-recording-access called'); - - if (process.platform === 'darwin') { - // On macOS, check if we have screen recording permission - const status = systemPreferences.getMediaAccessStatus('screen'); - res.json({ - status, - platform: 'darwin', - hasAccess: status === 'granted' - }); - } else { - // Non-macOS platforms don't need this permission - res.json({ - status: 'granted', - platform: process.platform, - hasAccess: true - }); - } - }); - - // Test endpoint - GET - router.get('/api/test/status', (req, res) => { - console.log('[Extension Test] API route GET /api/test/status called'); - res.json({ - status: 'ok', - message: 'Extension API routes are working!', - timestamp: new Date().toISOString() - }); - }); - - // Test endpoint - POST - router.post('/api/test/echo', (req, res) => { - const { message } = req.body; - console.log('[Extension Test] API route POST /api/test/echo called', { message }); - res.json({ - success: true, - echo: message, - processedAt: new Date().toISOString() - }); - }); - - // Test endpoint - GET with params - router.get('/api/test/info/:param', (req, res) => { - const { param } = req.params; - console.log('[Extension Test] API route GET /api/test/info/:param called', { param }); - res.json({ - success: true, - receivedParam: param, - query: req.query, - headers: req.headers - }); - }); - - // Media access status endpoint - router.get('/api/system/media-access-status/:mediaType', (req, res) => { - const { mediaType } = req.params; - console.log('[Extension Test] API route GET /api/system/media-access-status/:mediaType called', { mediaType }); - - if (process.platform === 'darwin') { - const status = systemPreferences.getMediaAccessStatus(mediaType); - res.json({ status }); - } else { - res.json({ status: 'granted' }); // Non-macOS platforms don't have this API - } - }); - - // Ask for media access endpoint - router.post('/api/system/ask-for-media-access', async (req, res) => { - const { mediaType } = req.body; - console.log('[Extension Test] API route POST /api/system/ask-for-media-access called', { mediaType }); - - if (process.platform === 'darwin') { - try { - const granted = await systemPreferences.askForMediaAccess(mediaType); - res.json({ granted }); - } catch (e) { - res.status(400).json({ - error: e.message, - }); - } - } else { - res.json({ granted: true }); // Non-macOS platforms don't need this - } - }); - } -}; \ No newline at end of file diff --git a/resources/js/pages/Conversations/Show.vue b/resources/js/pages/Conversations/Show.vue index 9ff3fa8..a81f1c9 100644 --- a/resources/js/pages/Conversations/Show.vue +++ b/resources/js/pages/Conversations/Show.vue @@ -3,6 +3,7 @@ import BaseCard from '@/components/design/BaseCard.vue'; import PageContainer from '@/components/design/PageContainer.vue'; import Button from '@/components/ui/button/Button.vue'; import AppLayout from '@/layouts/AppLayout.vue'; +import AudioPlayer from '@/components/AudioPlayer.vue'; import type { BreadcrumbItem } from '@/types'; import { Head, router } from '@inertiajs/vue3'; import axios from 'axios'; @@ -29,6 +30,10 @@ interface ConversationSession { total_action_items: number; ai_summary: string | null; user_notes: string | null; + has_recording: boolean; + recording_path: string | null; + recording_duration: number | null; + recording_size: number | null; } interface Transcript { @@ -89,6 +94,28 @@ const formatDuration = (seconds: number) => { return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; }; +const formatFileSize = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; +}; + +const getRecordingUrl = (recordingPath: string) => { + // Convert file path to URL for audio playback + // In Electron/NativePHP, we need to handle file:// URLs + if (recordingPath.startsWith('/')) { + return `file://${recordingPath}`; + } + return recordingPath; +}; + const getIntentColor = (intent: string) => { switch (intent) { case 'decision': @@ -265,12 +292,24 @@ const deleteConversation = async () => {

Transcript

{{ transcripts.length }} messages + + +
+ +

+ Left channel: Salesperson | Right channel: Customer +

+

No transcript available

-
+
+ > + + @@ -96,8 +104,10 @@ import CommitmentsList from '@/components/RealtimeAgent/Actions/CommitmentsList. import PostCallActions from '@/components/RealtimeAgent/Actions/PostCallActions.vue'; import ContextualInformation from '@/components/ContextualInformation.vue'; import OnboardingModal from '@/components/RealtimeAgent/OnboardingModal.vue'; +import RecordingIndicator from '@/components/RecordingIndicator.vue'; // Utils +import { AudioRecorderService } from '@/services/audioRecorder'; // Stores @@ -172,6 +182,14 @@ let micStream: MediaStream | null = null; let systemStream: MediaStream | null = null; let sessionsReady = false; +// Audio recording +let audioRecorder: AudioRecorderService | null = null; +let recordingDurationInterval: NodeJS.Timeout | null = null; +const isRecordingEnabled = ref(false); +const isRecording = ref(false); +const recordingDuration = ref(0); +const recordingPath = ref(''); + // Conversation session tracking const currentSessionId = ref(null); const isSavingData = ref(false); @@ -513,6 +531,61 @@ const startCall = async () => { // Start audio capture (pass permissions for conditional system audio) await startAudioCapture(permissions.screenCapture?.granted || false); + // Start audio recording if enabled + if (isRecordingEnabled.value && window.remote) { + try { + audioRecorder = new AudioRecorderService({ + sampleRate: 24000, // Match the audio context sample rate + channels: 2, + bitDepth: 16 + }); + recordingPath.value = await audioRecorder.start(); + isRecording.value = true; + recordingDuration.value = 0; + + // Start recording duration timer + recordingDurationInterval = setInterval(() => { + if (isRecording.value && audioRecorder) { + const status = audioRecorder.getStatus(); + recordingDuration.value = status.duration; + } + }, 1000); + + // Recording started successfully - no need to show in transcript + } catch (error) { + console.error('Failed to start recording:', error); + // Clean up any partial state + isRecording.value = false; + recordingPath.value = ''; + audioRecorder = null; + + // Determine error type and provide helpful message + let errorMessage = 'Failed to start recording'; + if (error.message.includes('Invalid recording path')) { + errorMessage = 'Invalid recording path - check file permissions'; + } else if (error.message.includes('No space left')) { + errorMessage = 'Not enough disk space for recording'; + } else if (error.message.includes('Permission denied')) { + errorMessage = 'Permission denied - check app file access'; + } else { + errorMessage = `Failed to start recording: ${error.message}`; + } + + realtimeStore.addTranscriptGroup({ + id: `system-recording-error-${Date.now()}`, + role: 'system', + messages: [{ + text: `⚠️ ${errorMessage}`, + timestamp: Date.now() + }], + startTime: Date.now(), + systemCategory: 'warning', + }); + + // Continue with call even if recording fails + } + } + realtimeStore.setActiveState(true); realtimeStore.setConnectionStatus('connected'); @@ -548,7 +621,7 @@ const endCall = async () => { // Save any remaining data before stopping if (currentSessionId.value) { await saveQueuedData(true); // Force save - await endConversationSession(); + // Don't end conversation session yet - need to save recording first } // Clear save interval @@ -567,6 +640,62 @@ const endCall = async () => { callStartTime.value = null; callDurationSeconds.value = 0; + // Stop recording if active + if (isRecording.value && audioRecorder) { + let recordingInfo = null; + try { + recordingInfo = await audioRecorder.stop(); + isRecording.value = false; + + // Update session with recording info if we have a session + if (currentSessionId.value && recordingInfo) { + try { + console.log('Updating recording info for session:', currentSessionId.value, recordingInfo); + const response = await axios.patch(`/conversations/${currentSessionId.value}/recording`, { + has_recording: true, + recording_path: recordingInfo.path, + recording_duration: recordingInfo.duration, + recording_size: recordingInfo.size, + }); + console.log('Recording info updated successfully:', response.data); + + // Recording saved successfully - no need to show in transcript + } catch (error) { + console.error('Failed to update session with recording info:', error); + // Recording saved but not linked - log silently + } + } else if (recordingInfo) { + console.warn('No session ID available to save recording info'); + // Recording saved but not linked - log silently + } + } catch (error) { + console.error('Failed to stop recording:', error); + isRecording.value = false; + + // Add error message + realtimeStore.addTranscriptGroup({ + id: `system-recording-stop-error-${Date.now()}`, + role: 'system', + messages: [{ + text: `❌ Failed to stop recording properly: ${error.message}. Recording may be corrupted.`, + timestamp: Date.now() + }], + startTime: Date.now(), + systemCategory: 'error', + }); + } finally { + // Always clean up resources + audioRecorder = null; + + // Clear recording duration interval + if (recordingDurationInterval) { + clearInterval(recordingDurationInterval); + recordingDurationInterval = null; + } + recordingDuration.value = 0; + } + } + // Stop microphone stream if (micStream) { micStream.getTracks().forEach(track => track.stop()); @@ -623,6 +752,11 @@ const endCall = async () => { // Clear from store openaiStore.clearAgents(); + // End conversation session after recording has been saved + if (currentSessionId.value) { + await endConversationSession(); + } + realtimeStore.setActiveState(false); realtimeStore.setConnectionStatus('disconnected'); @@ -1111,6 +1245,11 @@ const startAudioCapture = async (hasScreenCapturePermission: boolean = false) => // Convert to PCM16 and send audio const pcm16 = convertFloat32ToPCM16(inputData); + // Record audio if enabled (left channel - salesperson) + if (isRecording.value && audioRecorder) { + audioRecorder.appendAudio(pcm16, 'left'); + } + // Send to salesperson session if (salespersonSession && salespersonSession.transport) { const base64Audio = arrayBufferToBase64(pcm16.buffer); @@ -1164,6 +1303,11 @@ const startAudioCapture = async (hasScreenCapturePermission: boolean = false) => // Convert to PCM16 and send audio const pcm16 = convertFloat32ToPCM16(inputData); + // Record audio if enabled (right channel - customer) + if (isRecording.value && audioRecorder) { + audioRecorder.appendAudio(pcm16, 'right'); + } + // Send to coach session if (coachSession && coachSession.transport) { const base64Audio = arrayBufferToBase64(pcm16.buffer); @@ -1312,6 +1456,17 @@ const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { return base64Audio; }; +// Helper function to format file size +const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +}; + const handleDashboardClick = () => { if (!realtimeStore.isActive) { @@ -1341,8 +1496,10 @@ const startConversationSession = async () => { customer_company: realtimeStore.customerInfo.company || null, }); + console.log('Started conversation session:', response.data); currentSessionId.value = response.data.session_id; callStartTime.value = new Date(); + console.log('Session ID set to:', currentSessionId.value); // Start periodic saving saveInterval = setInterval(() => { @@ -1500,6 +1657,13 @@ watch(() => realtimeStore.transcriptGroups.map(g => g.messages.length), (newLeng onMounted(() => { initialize(); + // Load recording settings + const recordingSettings = localStorage.getItem('recordingSettings'); + if (recordingSettings) { + const settings = JSON.parse(recordingSettings); + isRecordingEnabled.value = settings.enabled ?? false; + } + // Close dropdowns on outside click document.addEventListener('click', () => { settingsStore.closeAllDropdowns(); diff --git a/resources/js/pages/settings/Recording.vue b/resources/js/pages/settings/Recording.vue new file mode 100644 index 0000000..6fbb622 --- /dev/null +++ b/resources/js/pages/settings/Recording.vue @@ -0,0 +1,349 @@ + + + \ No newline at end of file diff --git a/resources/js/services/audioRecorder.ts b/resources/js/services/audioRecorder.ts new file mode 100644 index 0000000..08c5f8f --- /dev/null +++ b/resources/js/services/audioRecorder.ts @@ -0,0 +1,398 @@ +// Audio recording service using Electron's fs module +interface AudioRecorderOptions { + sampleRate: number; + channels: number; + bitDepth: number; +} + +// Constants for better maintainability +const CONSTANTS = { + MAX_WRITE_SIZE: 10 * 1024 * 1024, // 10MB max per write + BUFFER_FLUSH_THRESHOLD: 4096, // Flush when buffer reaches 4KB + AUTO_SAVE_INTERVAL: 30000, // 30 seconds default + SAMPLE_RATE: 24000, // Default 24kHz + CHANNELS: 2, // Stereo + BIT_DEPTH: 16, // 16-bit +}; + +export class AudioRecorderService { + private options: AudioRecorderOptions; + private recordingPath: string = ''; + private fileDescriptor: number | null = null; + private audioBuffers: { + left: Int16Array[]; + right: Int16Array[]; + } = { left: [], right: [] }; + private bytesWritten: number = 0; + private autoSaveTimer: NodeJS.Timeout | null = null; + private autoSaveInterval: number = CONSTANTS.AUTO_SAVE_INTERVAL; + private startTime: number = 0; + + constructor(options: Partial = {}) { + this.options = { + sampleRate: options.sampleRate || CONSTANTS.SAMPLE_RATE, + channels: options.channels || CONSTANTS.CHANNELS, + bitDepth: options.bitDepth || CONSTANTS.BIT_DEPTH, + }; + + // Load auto-save interval from settings + const recordingSettings = localStorage.getItem('recordingSettings'); + if (recordingSettings) { + const settings = JSON.parse(recordingSettings); + this.autoSaveInterval = (settings.autoSaveInterval || 30) * 1000; + } + } + + /** + * Start recording + * @returns The path where the recording will be saved + */ + async start(): Promise { + if (this.fileDescriptor !== null) { + throw new Error('Recording already in progress'); + } + + this.startTime = Date.now(); + this.bytesWritten = 0; + this.audioBuffers = { left: [], right: [] }; + + // Generate filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const baseFilename = `conversation_${timestamp}.wav`; + + // Get recordings directory + const { app } = window.remote; + const fs = window.remote.require('fs'); + const path = window.remote.require('path'); + + // Sanitize filename to prevent path traversal + const sanitizedFilename = path.basename(baseFilename).replace(/[^a-zA-Z0-9._-]/g, '_'); + if (!sanitizedFilename.endsWith('.wav')) { + throw new Error('Invalid filename format'); + } + + const recordingsDir = path.join(app.getPath('userData'), 'recordings'); + + // Create directory if it doesn't exist + if (!fs.existsSync(recordingsDir)) { + fs.mkdirSync(recordingsDir, { recursive: true }); + } + + // Check available disk space + try { + const diskSpace = await this.checkDiskSpace(recordingsDir); + const minSpaceRequired = 100 * 1024 * 1024; // 100MB minimum + + if (diskSpace.free < minSpaceRequired) { + throw new Error(`Not enough disk space. Available: ${Math.floor(diskSpace.free / 1024 / 1024)}MB, Required: 100MB`); + } + } catch (error) { + // If we can't check disk space, log warning but continue + console.warn('Could not check disk space:', error); + } + + this.recordingPath = path.join(recordingsDir, sanitizedFilename); + + // Verify the final path is within the recordings directory + const resolvedPath = path.resolve(this.recordingPath); + const resolvedDir = path.resolve(recordingsDir); + if (!resolvedPath.startsWith(resolvedDir)) { + throw new Error('Invalid recording path'); + } + + // Create WAV file + this.createWavFile(); + + // Start auto-save timer + this.startAutoSave(); + + return this.recordingPath; + } + + /** + * Stop recording + * @returns Recording metadata + */ + async stop(): Promise<{ path: string; duration: number; size: number }> { + if (this.fileDescriptor === null) { + throw new Error('No recording in progress'); + } + + // Stop auto-save timer + this.stopAutoSave(); + + // Flush remaining buffers + this.flushBuffers(); + + // Finalize WAV file + this.finalizeWavFile(); + + const fs = window.remote.require('fs'); + const stats = fs.statSync(this.recordingPath); + const duration = this.getDuration(); + + return { + path: this.recordingPath, + duration, + size: stats.size, + }; + } + + /** + * Append audio data to the recording + * @param audioData PCM16 audio data + * @param channel 'left' or 'right' channel + */ + appendAudio(audioData: Int16Array, channel: 'left' | 'right'): void { + if (this.fileDescriptor === null) { + return; + } + + // Add to buffer + this.audioBuffers[channel].push(new Int16Array(audioData)); + } + + /** + * Get current recording status + */ + getStatus(): { isRecording: boolean; duration: number; path: string } { + return { + isRecording: this.fileDescriptor !== null, + duration: this.getDuration(), + path: this.recordingPath, + }; + } + + /** + * Get recording duration in seconds + */ + private getDuration(): number { + if (this.startTime === 0) { + return 0; + } + const duration = Math.floor((Date.now() - this.startTime) / 1000); + return duration; + } + + /** + * Create WAV file with header + */ + private createWavFile(): void { + const fs = window.remote.require('fs'); + + // Create file + this.fileDescriptor = fs.openSync(this.recordingPath, 'w'); + + // Write WAV header (we'll update sizes later) + const header = this.createWavHeader(0); + fs.writeSync(this.fileDescriptor, header); + this.bytesWritten = header.length; + } + + /** + * Create WAV header + */ + private createWavHeader(dataSize: number): Uint8Array { + const buffer = new Uint8Array(44); + const view = new DataView(buffer.buffer); + + // RIFF chunk + this.writeString(buffer, 'RIFF', 0); + view.setUint32(4, 36 + dataSize, true); // File size - 8 (little endian) + this.writeString(buffer, 'WAVE', 8); + + // fmt chunk + this.writeString(buffer, 'fmt ', 12); + view.setUint32(16, 16, true); // Subchunk size (little endian) + view.setUint16(20, 1, true); // Audio format (PCM) (little endian) + view.setUint16(22, this.options.channels, true); // Channels (little endian) + view.setUint32(24, this.options.sampleRate, true); // Sample rate (little endian) + view.setUint32(28, this.options.sampleRate * this.options.channels * 2, true); // Byte rate (little endian) + view.setUint16(32, this.options.channels * 2, true); // Block align (little endian) + view.setUint16(34, this.options.bitDepth, true); // Bits per sample (little endian) + + // data chunk + this.writeString(buffer, 'data', 36); + view.setUint32(40, dataSize, true); // Data size (little endian) + + return buffer; + } + + /** + * Write string to Uint8Array buffer + */ + private writeString(buffer: Uint8Array, str: string, offset: number): void { + for (let i = 0; i < str.length; i++) { + buffer[offset + i] = str.charCodeAt(i); + } + } + + /** + * Flush audio buffers to file + */ + private flushBuffers(): void { + if (this.fileDescriptor === null) { + return; + } + + const fs = window.remote.require('fs'); + + // Determine the maximum number of samples + const leftSamples = this.audioBuffers.left.reduce((sum, chunk) => sum + chunk.length, 0); + const rightSamples = this.audioBuffers.right.reduce((sum, chunk) => sum + chunk.length, 0); + const maxSamples = Math.max(leftSamples, rightSamples); + + if (maxSamples === 0) { + return; + } + + // Create interleaved stereo buffer + const stereoBuffer = new Uint8Array(maxSamples * 4); // 2 channels * 2 bytes per sample + const stereoView = new DataView(stereoBuffer.buffer); + + let bufferOffset = 0; + let leftChunkIndex = 0; + let rightChunkIndex = 0; + let leftChunkOffset = 0; + let rightChunkOffset = 0; + + // Interleave samples + for (let i = 0; i < maxSamples; i++) { + // Get left sample + let leftSample = 0; + if (leftChunkIndex < this.audioBuffers.left.length) { + const leftChunk = this.audioBuffers.left[leftChunkIndex]; + if (leftChunkOffset < leftChunk.length) { + leftSample = leftChunk[leftChunkOffset]; + leftChunkOffset++; + if (leftChunkOffset >= leftChunk.length) { + leftChunkIndex++; + leftChunkOffset = 0; + } + } + } + + // Get right sample + let rightSample = 0; + if (rightChunkIndex < this.audioBuffers.right.length) { + const rightChunk = this.audioBuffers.right[rightChunkIndex]; + if (rightChunkOffset < rightChunk.length) { + rightSample = rightChunk[rightChunkOffset]; + rightChunkOffset++; + if (rightChunkOffset >= rightChunk.length) { + rightChunkIndex++; + rightChunkOffset = 0; + } + } + } + + // Write interleaved samples (little endian) + stereoView.setInt16(bufferOffset, leftSample, true); + stereoView.setInt16(bufferOffset + 2, rightSample, true); + bufferOffset += 4; + } + + // Write to file with bounds checking + if (bufferOffset > 0) { + // Check for reasonable buffer size + if (bufferOffset > CONSTANTS.MAX_WRITE_SIZE) { + throw new Error(`Buffer size exceeds maximum write size: ${bufferOffset} bytes`); + } + + const dataToWrite = stereoBuffer.slice(0, bufferOffset); + fs.writeSync(this.fileDescriptor, dataToWrite, 0, bufferOffset, this.bytesWritten); + this.bytesWritten += bufferOffset; + } + + // Clear buffers + this.audioBuffers.left = []; + this.audioBuffers.right = []; + } + + /** + * Finalize WAV file by updating header sizes + */ + private finalizeWavFile(): void { + if (this.fileDescriptor === null) { + return; + } + + const fs = window.remote.require('fs'); + + // Update header with actual sizes + const dataSize = this.bytesWritten - 44; + const fileSize = this.bytesWritten - 8; + + // Update file size + const fileSizeBuffer = new Uint8Array(4); + const fileSizeView = new DataView(fileSizeBuffer.buffer); + fileSizeView.setUint32(0, fileSize, true); // little endian + fs.writeSync(this.fileDescriptor, fileSizeBuffer, 0, 4, 4); + + // Update data size + const dataSizeBuffer = new Uint8Array(4); + const dataSizeView = new DataView(dataSizeBuffer.buffer); + dataSizeView.setUint32(0, dataSize, true); // little endian + fs.writeSync(this.fileDescriptor, dataSizeBuffer, 0, 4, 40); + + // Close file + fs.closeSync(this.fileDescriptor); + this.fileDescriptor = null; + } + + /** + * Start auto-save timer + */ + private startAutoSave(): void { + this.autoSaveTimer = setInterval(() => { + this.flushBuffers(); + }, this.autoSaveInterval); + } + + /** + * Stop auto-save timer + */ + private stopAutoSave(): void { + if (this.autoSaveTimer) { + clearInterval(this.autoSaveTimer); + this.autoSaveTimer = null; + } + } + + /** + * Check available disk space + */ + private async checkDiskSpace(directory: string): Promise<{ free: number; size: number }> { + try { + // Try to use disk-space module if available + const diskSpace = window.remote.require('diskusage'); + const info = await new Promise<{ free: number; total: number }>((resolve, reject) => { + diskSpace.check(directory, (err: any, info: any) => { + if (err) reject(err); + else resolve(info); + }); + }); + return { free: info.free, size: info.total }; + } catch (error) { + // Fallback: Try to use fs.statfs (macOS/Linux) or native methods + const fs = window.remote.require('fs').promises; + const os = window.remote.require('os'); + + if (os.platform() === 'darwin' || os.platform() === 'linux') { + // On Unix-like systems, we can try statfs + try { + const stats = await fs.statfs(directory); + return { + free: stats.bavail * stats.bsize, + size: stats.blocks * stats.bsize + }; + } catch (e) { + // statfs might not be available + } + } + + // If all else fails, throw error + throw new Error('Cannot determine disk space'); + } + } +} \ No newline at end of file diff --git a/routes/settings.php b/routes/settings.php index 78d52c7..9e8687e 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -16,5 +16,9 @@ return Inertia::render('settings/Appearance'); })->name('appearance'); +Route::get('settings/recording', function () { + return Inertia::render('settings/Recording'); +})->name('recording'); + // Redirect settings to API keys (most important setting) Route::redirect('settings', '/settings/api-keys'); diff --git a/routes/web.php b/routes/web.php index 59505ec..c227eb0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -101,6 +101,8 @@ ->name('conversations.title'); Route::delete('/conversations/{session}', [\App\Http\Controllers\ConversationController::class, 'destroy']) ->name('conversations.destroy'); +Route::patch('/conversations/{session}/recording', [\App\Http\Controllers\ConversationController::class, 'updateRecording']) + ->name('conversations.recording'); // Variables Page Route Route::get('/variables', function () {