Compare commits
21
Commits
8a66cf6f63
..
stage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1898be4def | ||
|
|
3601ce8b6f | ||
|
|
5e76bfdec1 | ||
|
|
c6856c04d5 | ||
|
|
7b9bf3ac7f | ||
|
|
500a88a8c1 | ||
|
|
3a9cf50529 | ||
|
|
187b6b5d12 | ||
|
|
4c73620458 | ||
|
|
5cea5192c2 | ||
|
|
a1bddca2dd | ||
|
|
1877fee258 | ||
|
|
b3fb74fb1b | ||
|
|
f42d124593 | ||
|
|
2b126ee4e6 | ||
|
|
ab14f5e452 | ||
|
|
761f1a1f78 | ||
|
|
5dd0a3aeac | ||
|
|
4898d5cfde | ||
|
|
f65c816464 | ||
|
|
d60851bb53 |
@@ -0,0 +1,214 @@
|
||||
---
|
||||
name: echo-development
|
||||
description: "Develops real-time broadcasting with Laravel Echo. Activates when setting up broadcasting (Reverb, Pusher, Ably); creating ShouldBroadcast events; defining broadcast channels (public, private, presence, encrypted); authorizing channels; configuring Echo; listening for events; implementing client events (whisper); setting up model broadcasting; broadcasting notifications; or when the user mentions broadcasting, Echo, WebSockets, real-time events, Reverb, or presence channels."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Laravel Broadcasting & Echo
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Installing or configuring Laravel broadcasting (Reverb, Pusher, Ably)
|
||||
- Creating events that implement `ShouldBroadcast`
|
||||
- Defining broadcast channels and authorization
|
||||
- Setting up Laravel Echo on the client side
|
||||
- Listening for broadcast events, notifications, or model events
|
||||
- Implementing client-to-client events (whisper)
|
||||
- Working with presence channels for user awareness
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed broadcasting patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Installing Broadcasting
|
||||
|
||||
```bash
|
||||
php artisan install:broadcasting
|
||||
```
|
||||
|
||||
Use flags for specific drivers: `--reverb`, `--pusher`, `--ably`. This creates `config/broadcasting.php` and `routes/channels.php`.
|
||||
|
||||
### Creating a Broadcast Event
|
||||
|
||||
```bash
|
||||
php artisan make:event OrderShipped
|
||||
```
|
||||
|
||||
<!-- Broadcast Event -->
|
||||
```php
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderShipped implements ShouldBroadcast
|
||||
{
|
||||
use InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public Order $order) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('orders.'.$this->order->id)];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dispatch the event:
|
||||
|
||||
<!-- Dispatch Event -->
|
||||
```php
|
||||
use App\Events\OrderShipped;
|
||||
|
||||
OrderShipped::dispatch($order);
|
||||
```
|
||||
|
||||
### Authorizing Channels
|
||||
|
||||
Define authorization in `routes/channels.php`:
|
||||
|
||||
<!-- Channel Authorization -->
|
||||
```php
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
|
||||
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
|
||||
return $user->id === Order::findOrNew($orderId)->user_id;
|
||||
});
|
||||
```
|
||||
|
||||
Create a channel class for complex authorization:
|
||||
|
||||
```bash
|
||||
php artisan make:channel OrderChannel
|
||||
```
|
||||
|
||||
List all registered channels:
|
||||
|
||||
```bash
|
||||
php artisan channel:list
|
||||
```
|
||||
|
||||
### Client-Side Setup
|
||||
|
||||
Install Echo and Pusher JS:
|
||||
|
||||
```bash
|
||||
npm install --save-dev laravel-echo pusher-js
|
||||
```
|
||||
|
||||
<!-- Echo Client Configuration -->
|
||||
```javascript
|
||||
import Echo from 'laravel-echo';
|
||||
import Pusher from 'pusher-js';
|
||||
window.Pusher = Pusher;
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
```
|
||||
|
||||
### Listening for Events
|
||||
|
||||
<!-- Listen on Private Channel -->
|
||||
```javascript
|
||||
Echo.private(`orders.${orderId}`)
|
||||
.listen('OrderShipmentStatusUpdated', (e) => {
|
||||
console.log(e.order);
|
||||
});
|
||||
```
|
||||
|
||||
### Running Required Processes
|
||||
|
||||
```bash
|
||||
php artisan queue:work # Required for ShouldBroadcast events
|
||||
|
||||
php artisan reverb:start # Required for Reverb driver
|
||||
|
||||
```
|
||||
|
||||
## What's Possible
|
||||
|
||||
Use `search-docs` to find detailed code examples and configuration for each of these:
|
||||
|
||||
### Channel Types
|
||||
|
||||
- Public (`new Channel`) — no auth, anyone can subscribe. Use for app-wide announcements, public feeds, or status pages.
|
||||
- Private (`new PrivateChannel`) — requires authorization. Use for user-specific data like orders, messages, or account updates.
|
||||
- Presence (`new PresenceChannel`) — authorized + tracks who's online. Use for chat rooms, collaborative editing, "who's viewing this" features, or typing indicators.
|
||||
- EncryptedPrivate — end-to-end encryption, Pusher/Reverb only. Use when payload must be hidden from the broadcast server (e.g., sensitive financial data or private messages).
|
||||
- Drivers: `reverb` (self-hosted WebSocket server), `pusher` (managed service), `ably` (managed service), `log` (writes to Laravel log, use for debugging), `null` (no-op, use for testing)
|
||||
|
||||
### Event Customization
|
||||
|
||||
- `broadcastAs()` — custom event name (client must use dot prefix: `.listen('.custom.name')`). Use when you want stable API names decoupled from PHP class names, or shorter event names for the frontend.
|
||||
- `broadcastWith()` — control exact payload. Use to avoid leaking sensitive model attributes, slim down large payloads, or add computed data not on the model.
|
||||
- `broadcastWhen()` — conditional broadcasting. Use to skip broadcasting when changes are trivial (e.g., only broadcast order updates above a threshold, or skip unchanged fields).
|
||||
- `broadcastQueue()` / `$queue` — route to specific queue. Use to isolate real-time broadcasts from slow background jobs so they're processed faster.
|
||||
- `$connection` — set queue connection per event. Use when broadcasts should go through a faster queue backend like Redis while other jobs use the database driver.
|
||||
|
||||
### Broadcasting Interfaces
|
||||
|
||||
- `ShouldBroadcast` — queue the broadcast (default). Use for most events to avoid blocking the HTTP response.
|
||||
- `ShouldBroadcastNow` — broadcast synchronously, skip queue. Use during development or for time-critical events where queue latency is unacceptable.
|
||||
- `ShouldDispatchAfterCommit` — wait for DB transaction commit. Use when the event references newly created records that listeners need to query (prevents race conditions).
|
||||
- `ShouldRescue` — auto-catch broadcast exceptions. Use to prevent broadcast failures (e.g., WebSocket server down) from disrupting the user's HTTP request.
|
||||
- `InteractsWithSockets` — required for `toOthers()`. Use on any event where you want to exclude the sender (optimistic UI updates).
|
||||
- `InteractsWithBroadcasting` — override driver per event via `broadcastVia()`. Use in multi-driver setups (e.g., some events via Reverb, others via Pusher).
|
||||
|
||||
### Broadcasting Helpers
|
||||
|
||||
- `broadcast(new Event)->toOthers()` — exclude current user's socket. Use when the client already updates optimistically from the API response to avoid duplicate updates.
|
||||
- `broadcast(new Event)->via('pusher')` — override connection. Use to route specific events through a different broadcast driver than the default.
|
||||
- `Broadcast::on()`, `Broadcast::private()`, `Broadcast::presence()` — anonymous broadcasting without event classes. Chain `.as('name')->with($data)->send()` or `.sendNow()`. Use for simple one-off broadcasts where creating a full event class is overkill (e.g., quick status updates, simple notifications).
|
||||
|
||||
### Channel Authorization
|
||||
|
||||
- Closure-based in `routes/channels.php` — use for simple authorization logic (e.g., checking ownership).
|
||||
- Model binding: `Broadcast::channel('orders.{order}', fn (User $user, Order $order) => ...)` — use when authorization depends on the model instance (auto-resolves from route parameter).
|
||||
- Channel classes via `php artisan make:channel` — use for complex authorization logic that benefits from dependency injection or reusable logic across channels.
|
||||
- Multiple guards: `['guards' => ['web', 'admin']]` — use when the channel should be accessible by users authenticated via different guards (e.g., both regular users and admins).
|
||||
|
||||
### Model Broadcasting
|
||||
|
||||
- `BroadcastsEvents` trait auto-broadcasts created/updated/deleted/trashed/restored. Use to automatically keep clients in sync with Eloquent model changes without writing individual events.
|
||||
- Channel convention: `App.Models.Post.{id}` — clients subscribe to model-specific channels.
|
||||
- `broadcastAs($event)` and `broadcastWith($event)` for per-action customization. Use to send different payloads for create vs update, or suppress certain event types.
|
||||
- `newBroadcastableEvent($event)` for event instance customization (e.g., `->dontBroadcastToCurrentUser()`). Use when you need to modify the underlying event object before it's dispatched.
|
||||
|
||||
### Client-Side Features
|
||||
|
||||
- Client events: `whisper()` / `listenForWhisper()` — peer-to-peer without server roundtrip (private/presence channels only). Use for typing indicators, cursor positions, or any ephemeral state that doesn't need server persistence.
|
||||
- Presence channels: `Echo.join()` with `here()`, `joining()`, `leaving()`, `error()` callbacks. Use for showing online users, "X is viewing this document" features, or live participant counts.
|
||||
- Notification broadcasting: `.notification()` on user's private channel. Use to show real-time notifications (toast, badge counts) pushed from Laravel's notification system.
|
||||
- Connection management: `Echo.connectionStatus()`, `Echo.leaveAllChannels()`, `Echo.disconnect()`. Use to show connection indicators, clean up on logout, or handle offline/reconnect scenarios.
|
||||
- Custom namespace: `new Echo({ namespace: 'App.Other.Namespace' })`. Use when your events live outside the default `App\Events` namespace.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Queue worker must be running for `ShouldBroadcast` events. Use `ShouldBroadcastNow` during development.
|
||||
- `BROADCAST_CONNECTION` not `BROADCAST_DRIVER`: Laravel 11+ renamed this env key.
|
||||
- `toOthers()` requires `InteractsWithSockets` trait AND `X-Socket-ID` header. Echo auto-adds this to global Axios. For `fetch`, manually send `Echo.socketId()`.
|
||||
- CORS: When frontend/backend are on different origins, add `broadcasting/auth` to `config/cors.php` paths and set `supports_credentials` to `true`.
|
||||
- Missing `VITE_` prefix: Client-side env vars must start with `VITE_`.
|
||||
- `channels.php` not loaded: Verify it's included in `withRouting()` in `bootstrap/app.php`.
|
||||
- Reverb is long-running: Code changes require `php artisan reverb:restart`.
|
||||
- Presence channel auth must return an array of user data (`['id' => $user->id, 'name' => $user->name]`), not `true`. Returning `true` silently fails.
|
||||
- Dot prefix rule: When using `broadcastAs()`, client must prefix with `.` (e.g., `.listen('.custom.name')`). Without the dot, Echo looks for `App\Events\custom.name` which silently fails.
|
||||
- Reverb host separation: `REVERB_SERVER_HOST`/`REVERB_SERVER_PORT` (internal bind) vs `REVERB_HOST`/`REVERB_PORT` (public address) vs `VITE_REVERB_HOST`/`VITE_REVERB_PORT` (client JS).
|
||||
- Sanctum SPA auth: Ensure `/broadcasting/auth` uses `auth:sanctum` middleware and CSRF tokens are sent with `withCredentials: true`.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: fluxui-development
|
||||
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Flux UI Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Flux UI patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
|
||||
|
||||
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
|
||||
|
||||
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
|
||||
|
||||
<!-- Basic Button -->
|
||||
```blade
|
||||
<flux:button variant="primary">Click me</flux:button>
|
||||
```
|
||||
|
||||
## Available Components (Free Edition)
|
||||
|
||||
Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
|
||||
|
||||
## Icons
|
||||
|
||||
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
|
||||
|
||||
<!-- Icon Button -->
|
||||
```blade
|
||||
<flux:button icon="arrow-down-tray">Export</flux:button>
|
||||
```
|
||||
|
||||
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
|
||||
|
||||
```bash
|
||||
php artisan flux:icon crown grip-vertical github
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Form Fields
|
||||
|
||||
<!-- Form Field -->
|
||||
```blade
|
||||
<flux:field>
|
||||
<flux:label>Email</flux:label>
|
||||
<flux:input type="email" wire:model="email" />
|
||||
<flux:error name="email" />
|
||||
</flux:field>
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
<!-- Modal -->
|
||||
```blade
|
||||
<flux:modal wire:model="showModal">
|
||||
<flux:heading>Title</flux:heading>
|
||||
<p>Content</p>
|
||||
</flux:modal>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check component renders correctly
|
||||
2. Test interactive states
|
||||
3. Verify mobile responsiveness
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Trying to use Pro-only components in the free edition
|
||||
- Not checking if a Flux component exists before creating custom implementations
|
||||
- Forgetting to use the `search-docs` tool for component-specific documentation
|
||||
- Not following existing project patterns for Flux usage
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: fortify-development
|
||||
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Laravel Fortify Development
|
||||
|
||||
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
|
||||
|
||||
## Usage
|
||||
|
||||
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
|
||||
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
|
||||
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
|
||||
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
|
||||
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
|
||||
|
||||
## Available Features
|
||||
|
||||
Enable in `config/fortify.php` features array:
|
||||
|
||||
- `Features::registration()` - User registration
|
||||
- `Features::resetPasswords()` - Password reset via email
|
||||
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
|
||||
- `Features::updateProfileInformation()` - Profile updates
|
||||
- `Features::updatePasswords()` - Password changes
|
||||
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
||||
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
|
||||
|
||||
> Use `search-docs` for feature configuration options and customization patterns.
|
||||
|
||||
## Setup Workflows
|
||||
|
||||
### Two-Factor Authentication Setup
|
||||
|
||||
```
|
||||
- [ ] Add TwoFactorAuthenticatable trait to User model
|
||||
- [ ] Enable feature in config/fortify.php
|
||||
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||
- [ ] Set up view callbacks in FortifyServiceProvider
|
||||
- [ ] Create 2FA management UI
|
||||
- [ ] Test QR code and recovery codes
|
||||
```
|
||||
|
||||
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
||||
|
||||
### Passkeys Setup
|
||||
|
||||
```
|
||||
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
|
||||
- [ ] Enable passkeys feature in config/fortify.php
|
||||
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
|
||||
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
|
||||
```
|
||||
|
||||
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
|
||||
|
||||
### Email Verification Setup
|
||||
|
||||
```
|
||||
- [ ] Enable emailVerification feature in config
|
||||
- [ ] Implement MustVerifyEmail interface on User model
|
||||
- [ ] Set up verifyEmailView callback
|
||||
- [ ] Add verified middleware to protected routes
|
||||
- [ ] Test verification email flow
|
||||
```
|
||||
|
||||
> Use `search-docs` for MustVerifyEmail implementation patterns.
|
||||
|
||||
### Password Reset Setup
|
||||
|
||||
```
|
||||
- [ ] Enable resetPasswords feature in config
|
||||
- [ ] Set up requestPasswordResetLinkView callback
|
||||
- [ ] Set up resetPasswordView callback
|
||||
- [ ] Define password.reset named route (if views disabled)
|
||||
- [ ] Test reset email and link flow
|
||||
```
|
||||
|
||||
> Use `search-docs` for custom password reset flow patterns.
|
||||
|
||||
### SPA Authentication Setup
|
||||
|
||||
```
|
||||
- [ ] Set 'views' => false in config/fortify.php
|
||||
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
|
||||
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
|
||||
- [ ] Set up CSRF token handling
|
||||
- [ ] Test XHR authentication flows
|
||||
```
|
||||
|
||||
> Use `search-docs` for integration and SPA authentication patterns.
|
||||
|
||||
#### Two-Factor Authentication in SPA Mode
|
||||
|
||||
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
|
||||
|
||||
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
|
||||
|
||||
```json
|
||||
{
|
||||
"two_factor": true
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Custom Authentication Logic
|
||||
|
||||
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
|
||||
|
||||
### Registration Customization
|
||||
|
||||
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
| Feature | Method | Endpoint |
|
||||
|------------------------|----------|---------------------------------------------|
|
||||
| Login | POST | `/login` |
|
||||
| Logout | POST | `/logout` |
|
||||
| Register | POST | `/register` |
|
||||
| Password Reset Request | POST | `/forgot-password` |
|
||||
| Password Reset | POST | `/reset-password` |
|
||||
| Email Verify Notice | GET | `/email/verify` |
|
||||
| Resend Verification | POST | `/email/verification-notification` |
|
||||
| Password Confirm | POST | `/user/confirm-password` |
|
||||
| Enable 2FA | POST | `/user/two-factor-authentication` |
|
||||
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
|
||||
| 2FA Challenge | POST | `/two-factor-challenge` |
|
||||
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
||||
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
||||
| Passkey Login Options | GET | `/passkeys/login/options` |
|
||||
| Passkey Login | POST | `/passkeys/login` |
|
||||
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
|
||||
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||
| Passkey Options | GET | `/user/passkeys/options` |
|
||||
| Register Passkey | POST | `/user/passkeys` |
|
||||
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||
@@ -30,7 +30,7 @@ Fan out when you can. The sweep is embarrassingly parallel. If your environment
|
||||
|
||||
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||
|
||||
This app has no Livewire/Inertia/Flux packages installed. Treat the frontend group as likely API-only: confirm from `resources/views` before spending time there, and skip the Livewire/Inertia/Flux dimensions.
|
||||
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||
|
||||
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||
|
||||
|
||||
@@ -77,12 +77,16 @@ Each item gives the fork, then a hint (a grep or dir to spot which side the app
|
||||
|
||||
## F. Frontend & views
|
||||
|
||||
No Livewire/Inertia/Flux package is installed. This app may be API-only. Confirm from `resources/views` before sweeping, and treat the Livewire/Flux dimensions as not applicable.
|
||||
This app ships a frontend stack, so the items below apply.
|
||||
|
||||
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
|
||||
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
|
||||
31. UI kit (Flux): Flux components vs custom components vs another library.
|
||||
- Hint: grep `<flux:` in `resources/views`.
|
||||
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: livewire-development
|
||||
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Livewire Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Livewire 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Components
|
||||
|
||||
```bash
|
||||
|
||||
# Single-file component (SFC - default in v4)
|
||||
|
||||
# Creates: resources/views/components/⚡create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post
|
||||
|
||||
# Page component (SFC - Full Page in v4)
|
||||
|
||||
# Creates: resources/views/pages/⚡create-post.blade.php
|
||||
|
||||
php artisan make:livewire pages::create-post
|
||||
|
||||
# Multi-file component (MFC)
|
||||
|
||||
# Creates: resources/views/components/⚡create-post/create-post.php
|
||||
|
||||
# resources/views/components/⚡create-post/create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post --mfc
|
||||
|
||||
# Class-based component (v3 style)
|
||||
|
||||
# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post --class
|
||||
|
||||
# With namespace
|
||||
|
||||
php artisan make:livewire Posts/CreatePost
|
||||
```
|
||||
|
||||
### Converting Between Formats
|
||||
|
||||
Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
|
||||
|
||||
### Choosing a Component Format
|
||||
|
||||
> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.
|
||||
|
||||
Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.
|
||||
|
||||
### Component Format Reference
|
||||
|
||||
| Format | Flag | Class Path | View Path |
|
||||
|--------|------|------------|-----------|
|
||||
| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
|
||||
| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
|
||||
| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
|
||||
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
|
||||
| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |
|
||||
|
||||
> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.
|
||||
|
||||
Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.
|
||||
|
||||
### Single-File Component Example
|
||||
|
||||
<!-- Single-File Component Example -->
|
||||
```php
|
||||
<?php
|
||||
use Livewire\Component;
|
||||
|
||||
new class extends Component {
|
||||
public int $count = 0;
|
||||
|
||||
public function increment(): void
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div>
|
||||
<button wire:click="increment">Count: @{{ $count }}</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Livewire 4 Specifics
|
||||
|
||||
### Key Changes From Livewire 3
|
||||
|
||||
These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
|
||||
|
||||
- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
|
||||
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
|
||||
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
|
||||
- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
|
||||
|
||||
### New Features
|
||||
|
||||
- Component formats: single-file (SFC), multi-file (MFC), view-based components.
|
||||
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
|
||||
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
|
||||
|
||||
| Feature | Usage | Purpose |
|
||||
|---------|-------|---------|
|
||||
| Islands | `@island(name: 'stats')` | Isolated update regions |
|
||||
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
|
||||
| Deferred | `defer` attribute | Load after page render |
|
||||
| Bundled | `lazy.bundle` | Load multiple together |
|
||||
|
||||
### New Directives
|
||||
|
||||
- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
|
||||
- `data-loading` attribute automatically added to elements triggering network requests.
|
||||
|
||||
| Directive | Purpose |
|
||||
|-----------|---------|
|
||||
| `wire:sort` | Drag-and-drop sorting |
|
||||
| `wire:intersect` | Viewport intersection detection |
|
||||
| `wire:ref` | Element references for JS |
|
||||
| `.renderless` | Component without rendering |
|
||||
| `.preserve-scroll` | Preserve scroll position |
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use `wire:key` in loops
|
||||
- Use `wire:loading` for loading states
|
||||
- Use `wire:model.live` for instant updates (default is debounced)
|
||||
- Validate and authorize in actions (treat like HTTP requests)
|
||||
|
||||
## Configuration
|
||||
|
||||
- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
|
||||
|
||||
## Alpine & JavaScript
|
||||
|
||||
- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
|
||||
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
|
||||
|
||||
For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Testing Example -->
|
||||
```php
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1);
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Browser console: Check for JS errors
|
||||
2. Network tab: Verify Livewire requests return 200
|
||||
3. Ensure `wire:key` on all `@foreach` loops
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Missing `wire:key` in loops → unexpected re-rendering
|
||||
- Expecting `wire:model` real-time → use `wire:model.live`
|
||||
- Unclosed component tags → syntax errors in v4
|
||||
- Using deprecated config keys or JS hooks
|
||||
- Including Alpine.js separately (already bundled in Livewire 4)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Livewire 4 JavaScript Integration
|
||||
|
||||
## Interceptor System (v4)
|
||||
|
||||
### Intercept Messages
|
||||
|
||||
```js
|
||||
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
|
||||
onFinish(() => { /* After response, before processing */ });
|
||||
onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
|
||||
onError(() => { /* Server errors */ });
|
||||
});
|
||||
```
|
||||
|
||||
### Intercept Requests
|
||||
|
||||
```js
|
||||
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
|
||||
onResponse(({ response }) => { /* When received */ });
|
||||
onSuccess(({ response, responseJson }) => { /* Success */ });
|
||||
onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
|
||||
onFailure(({ error }) => { /* Network failures */ });
|
||||
});
|
||||
```
|
||||
|
||||
### Component-Scoped Interceptors
|
||||
|
||||
```blade
|
||||
<script>
|
||||
this.$intercept('save', ({ component, onSuccess }) => {
|
||||
onSuccess(() => console.log('Saved!'));
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Magic Properties
|
||||
|
||||
- `$errors` - Access validation errors from JavaScript
|
||||
- `$intercept` - Component-scoped interceptors
|
||||
+13
-3
@@ -3,6 +3,8 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8080
|
||||
# Production/CI: set to the Gitea registry image (local Compose builds andytranscribe-app:latest).
|
||||
# APP_IMAGE=gitea.z00.nu/ben/andytranscribe:latest
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -27,7 +29,7 @@ DB_CONNECTION=sqlite
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
@@ -37,7 +39,7 @@ BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=file
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
@@ -64,7 +66,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Laravel Reverb (WebSockets). Browser uses VITE_*; server publish uses REVERB_HOST.
|
||||
# Laravel Reverb (WebSockets). Browser uses Blade meta (reverb.client) with VITE_* fallback.
|
||||
REVERB_APP_ID=andytranscribe
|
||||
REVERB_APP_KEY=andytranscribe-key
|
||||
REVERB_APP_SECRET=andytranscribe-secret
|
||||
@@ -73,11 +75,19 @@ REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
REVERB_SERVER_HOST=0.0.0.0
|
||||
REVERB_SERVER_PORT=8080
|
||||
# Public Echo target (z00 stage/prod set these in the instance .env).
|
||||
# REVERB_PUBLIC_HOST=reverb.transcribe.z00.nu
|
||||
# REVERB_PUBLIC_PORT=443
|
||||
# REVERB_PUBLIC_SCHEME=https
|
||||
|
||||
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
|
||||
APP_HOST_PORT=8080
|
||||
REVERB_HOST_PORT=8081
|
||||
WHISPER_HOST_PORT=8090
|
||||
# Unique per Compose instance on a shared Docker host (prod default keeps existing names).
|
||||
# CONTAINER_PREFIX=andytranscribe
|
||||
# CADDY_NETWORK=caddy-proxy-manager-test_caddy-test-network
|
||||
# PUBLIC_APP_URL=https://transcribe.z00.nu
|
||||
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST=localhost
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- stage
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.z00.nu
|
||||
# Fallback baked into the Vite bundle; public pages prefer Blade meta tags.
|
||||
VITE_REVERB_HOST: reverb.transcribe.z00.nu
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: https
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .env.example .env
|
||||
php artisan key:generate --force --no-interaction
|
||||
php -d memory_limit=512M artisan test --compact
|
||||
|
||||
- name: Validate compose
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_IMAGE="${REGISTRY}/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]'):test" \
|
||||
APP_KEY="base64:dGVzdC1hcHAta2V5LWZvci1jaS1jb21wb3NlLXZhbGlkYXRpb24=" \
|
||||
docker compose -f docker-compose.yml -f compose.z00.yaml config --quiet
|
||||
|
||||
- name: Validate stage compose
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_IMAGE="${REGISTRY}/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]'):test" \
|
||||
APP_KEY="base64:dGVzdC1hcHAta2V5LWZvci1jaS1jb21wb3NlLXZhbGlkYXRpb24=" \
|
||||
PUBLIC_APP_URL="https://stage.transcribe.z00.nu" \
|
||||
APP_URL="https://stage.transcribe.z00.nu" \
|
||||
REVERB_PUBLIC_HOST="reverb.stage.transcribe.z00.nu" \
|
||||
CONTAINER_PREFIX="andytranscribe-stage" \
|
||||
CADDY_NETWORK="andytranscribe-stage-caddy" \
|
||||
docker compose -f docker-compose.yml -f compose.z00.yaml config --quiet
|
||||
|
||||
build-and-push:
|
||||
if: gitea.event_name != 'pull_request'
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Build and push image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
TAG_SHA="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
|
||||
TAG_LATEST="${REGISTRY}/${REPO_LC}:latest"
|
||||
TAG_STAGE="${REGISTRY}/${REPO_LC}:stage"
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${REGISTRY}" -u "${{ gitea.actor }}" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg VITE_APP_NAME=AndyTranscribe \
|
||||
--build-arg VITE_REVERB_APP_KEY=andytranscribe-key \
|
||||
--build-arg "VITE_REVERB_HOST=${VITE_REVERB_HOST}" \
|
||||
--build-arg "VITE_REVERB_PORT=${VITE_REVERB_PORT}" \
|
||||
--build-arg "VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}" \
|
||||
-t "${TAG_SHA}" \
|
||||
.
|
||||
docker push "${TAG_SHA}"
|
||||
if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then
|
||||
docker tag "${TAG_SHA}" "${TAG_LATEST}"
|
||||
docker push "${TAG_LATEST}"
|
||||
elif [ "${{ gitea.ref }}" = "refs/heads/stage" ]; then
|
||||
docker tag "${TAG_SHA}" "${TAG_STAGE}"
|
||||
docker push "${TAG_STAGE}"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy production
|
||||
env:
|
||||
DEPLOY_PATHS: ${{ secrets.DEPLOY_PATHS }}
|
||||
DEPLOY_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
export APP_IMAGE="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
|
||||
chmod +x scripts/deploy-production.sh
|
||||
if [ -z "${DEPLOY_PATHS:-}" ]; then
|
||||
echo "DEPLOY_PATHS secret is not set; skipping deploy."
|
||||
echo "Built image: ${APP_IMAGE}"
|
||||
exit 0
|
||||
fi
|
||||
./scripts/deploy-production.sh
|
||||
|
||||
deploy-stage:
|
||||
if: gitea.ref == 'refs/heads/stage' && gitea.event_name != 'pull_request'
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy stage
|
||||
env:
|
||||
DEPLOY_PATHS: ${{ secrets.STAGE_DEPLOY_PATHS }}
|
||||
DEPLOY_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
export APP_IMAGE="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
|
||||
chmod +x scripts/deploy-production.sh
|
||||
if [ -z "${DEPLOY_PATHS:-}" ]; then
|
||||
echo "STAGE_DEPLOY_PATHS secret is not set; skipping deploy."
|
||||
echo "Built image: ${APP_IMAGE}"
|
||||
exit 0
|
||||
fi
|
||||
./scripts/deploy-production.sh
|
||||
@@ -21,6 +21,7 @@
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/storage/app/private/truss
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
},
|
||||
"truss": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"mcp:start",
|
||||
"truss"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||
|
||||
Before relying on a package's API, confirm its installed version:
|
||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||
- JS packages: check `package.json` for the installed versions.
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
## Tools
|
||||
|
||||
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||
|
||||
## Searching Documentation (IMPORTANT)
|
||||
|
||||
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Search Syntax
|
||||
|
||||
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||
|
||||
## Project Rules
|
||||
|
||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||
|
||||
## Tinker
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||
- Use array shape type definitions in PHPDoc blocks.
|
||||
|
||||
=== deployments rules ===
|
||||
|
||||
# Deployment
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
## APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- Every time a test has been updated, run that singular test.
|
||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
@@ -104,6 +104,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
@@ -134,6 +141,14 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
@@ -154,6 +154,52 @@ Then a normal `docker compose up -d` enables:
|
||||
- `queue:listen` so worker code picks up changes between jobs
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080) as usual. After changing Composer packages, run `docker compose exec app composer install`.
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
Gitea Actions (host runner on z00) on push:
|
||||
|
||||
| Branch | Image tags | Deploy target |
|
||||
| --- | --- | --- |
|
||||
| `main` | `<sha>` and `:latest` | `~/andyTranscibe` → https://transcribe.z00.nu |
|
||||
| `stage` | `<sha>` and `:stage` | `~/andyTranscibe-stage` → https://stage.transcribe.z00.nu |
|
||||
|
||||
Both runs:
|
||||
|
||||
1. PHPUnit (+ compose config check for prod and stage env)
|
||||
2. Build and push `gitea.z00.nu/ben/andytranscribe:<sha>` — Vite assets are baked in; browser Reverb host/key come from Blade meta at runtime
|
||||
3. Hard-reset the matching checkout and pull the image (`docker-compose.yml` + `compose.z00.yaml`)
|
||||
|
||||
Stage is a separate Compose project: own SQLite, uploads, `APP_KEY`, Reverb credentials, container names (`andytranscribe-stage-*`), host ports, and Caddy Docker network (`andytranscribe-stage-caddy`). Do not copy prod `.env` or data into the stage directory.
|
||||
|
||||
Do not hot-patch production or staging containers or their deploy checkouts. Fix in git; push `stage` to preview, then `main` to ship.
|
||||
|
||||
One-time server bootstrap (secrets + registry login):
|
||||
|
||||
```bash
|
||||
./scripts/setup-gitea-ci.sh
|
||||
./scripts/setup-stage.sh
|
||||
```
|
||||
|
||||
`setup-stage.sh` clones `$HOME/andyTranscibe-stage` from Gitea (not the prod checkout), writes a unique `.env`, creates `andytranscribe-stage-caddy`, and attaches Caddy Proxy Manager to that network. You still need DNS for `stage.transcribe.z00.nu` and `reverb.stage.transcribe.z00.nu`, plus Proxy Manager hosts:
|
||||
|
||||
- `stage.transcribe.z00.nu` → `andytranscribe-stage-app:80`
|
||||
- `reverb.stage.transcribe.z00.nu` → `andytranscribe-stage-reverb:8080`
|
||||
|
||||
Create and push the branch after the workflow file is on the default branch:
|
||||
|
||||
```bash
|
||||
git checkout -b stage
|
||||
git push -u origin stage
|
||||
```
|
||||
|
||||
Manual deploy of an already-built tag:
|
||||
|
||||
```bash
|
||||
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh
|
||||
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> DEPLOY_PATHS=$HOME/andyTranscibe-stage ./scripts/deploy-production.sh
|
||||
```
|
||||
|
||||
## Services and ports
|
||||
|
||||
| Service | Host port | Role |
|
||||
@@ -181,21 +227,22 @@ Edit `.env` before `docker compose up` when you need different ports or models:
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `APP_KEY` | Required Laravel encryption key | — |
|
||||
| `APP_IMAGE` | Pre-built image for CI/prod deploys (omit locally) | `andytranscribe-app:latest` |
|
||||
| `APP_URL` | Public app URL | `http://localhost:8080` |
|
||||
| `APP_HOST_PORT` | Host port for the web app | `8080` |
|
||||
| `REVERB_HOST_PORT` | Host port for WebSockets | `8081` |
|
||||
| `WHISPER_HOST_PORT` | Host port for Whisper | `8090` |
|
||||
| `REVERB_PUBLIC_HOST` | Browser WebSocket host (Blade meta) | `REVERB_HOST` |
|
||||
| `CONTAINER_PREFIX` | Docker `container_name` prefix | `andytranscribe` |
|
||||
| `CADDY_NETWORK` | External Caddy network (z00 overlay) | `caddy-proxy-manager-test_caddy-test-network` |
|
||||
| `PUBLIC_APP_URL` | Public URL injected by the z00 overlay (unset = prod) | `https://transcribe.z00.nu` |
|
||||
| `LOCAL_WHISPER_MODEL` | Whisper model id | `Systran/faster-whisper-base` |
|
||||
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout (seconds) | `600` |
|
||||
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout (seconds). Hung Whisper calls fail the job; UI can restart. | `600` |
|
||||
| `DB_QUEUE_RETRY_AFTER` | Must exceed `TRANSCRIPTION_TIMEOUT` | `660` |
|
||||
|
||||
Inside Compose, Laravel talks to Whisper at `http://whisper:8000/v1` and publishes broadcasts to the `reverb` service. The browser connects to Reverb on `localhost:8081`.
|
||||
Inside Compose, Laravel talks to Whisper at `http://whisper:8000/v1` and publishes broadcasts to the `reverb` service. The browser connects using Reverb settings from the HTML meta tags (with Vite `VITE_REVERB_*` as a local fallback).
|
||||
|
||||
If you change `REVERB_APP_KEY` or browser-facing Reverb host/port settings, rebuild so Vite embeds the new values:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
If you change `REVERB_APP_KEY` or browser-facing Reverb host/port settings, restart the app container so Blade picks up the new values. An image rebuild is only needed when Vite-baked fallbacks must change.
|
||||
|
||||
### GPU Whisper (optional)
|
||||
|
||||
@@ -256,6 +303,20 @@ composer test
|
||||
php artisan test
|
||||
```
|
||||
|
||||
## Schema viewer (Laravel Truss)
|
||||
|
||||
[Laravel Truss](https://github.com/albertoarena/laravel-truss) is installed as a **dev** dependency. In `local`, open:
|
||||
|
||||
```
|
||||
/truss
|
||||
```
|
||||
|
||||
It shows a live ER diagram of your schema (structure only, never row data). Optional agent MCP:
|
||||
|
||||
```bash
|
||||
php artisan mcp:start truss
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -14,9 +14,21 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
* Reverb's default max payload is 10KB. Keep streamed text well under that.
|
||||
*/
|
||||
public function __construct(public Recording $recording) {}
|
||||
public const MAX_DELTA_BYTES = 8_000;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param array<string, mixed>|null $whisperDelta
|
||||
*/
|
||||
public function __construct(
|
||||
public Recording $recording,
|
||||
public ?string $transcriptDelta = null,
|
||||
public bool $transcriptReplace = false,
|
||||
public ?array $whisperDelta = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
@@ -41,11 +53,89 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return array_merge(
|
||||
$payload = array_merge(
|
||||
$this->recording->transcriptionStatusPayload(),
|
||||
[
|
||||
'word_count' => $this->recording->word_count,
|
||||
],
|
||||
);
|
||||
|
||||
unset($payload['transcript'], $payload['whisper']);
|
||||
|
||||
$delta = $this->transcriptDelta;
|
||||
|
||||
if ($delta !== null && $delta !== '' && strlen($delta) <= self::MAX_DELTA_BYTES) {
|
||||
$payload['transcript_delta'] = $delta;
|
||||
$payload['transcript_replace'] = $this->transcriptReplace;
|
||||
}
|
||||
|
||||
$whisper = $this->compactWhisperDelta($this->whisperDelta);
|
||||
|
||||
if ($whisper !== null) {
|
||||
$payload['whisper_delta'] = $whisper;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $whisper
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function compactWhisperDelta(?array $whisper): ?array
|
||||
{
|
||||
if ($whisper === null || $whisper === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Accumulated segment lists belong in HTTP hydrate, not on Reverb.
|
||||
unset($whisper['segments']);
|
||||
|
||||
if ($whisper === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['tokens', 'logprobs', 'words'] as $drop) {
|
||||
$encoded = json_encode($whisper);
|
||||
|
||||
if (! is_string($encoded) || strlen($encoded) <= self::MAX_DELTA_BYTES) {
|
||||
return $whisper;
|
||||
}
|
||||
|
||||
$whisper = $this->dropWhisperField($whisper, $drop);
|
||||
}
|
||||
|
||||
$encoded = json_encode($whisper);
|
||||
|
||||
if (! is_string($encoded) || strlen($encoded) > self::MAX_DELTA_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $whisper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $whisper
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function dropWhisperField(array $whisper, string $field): array
|
||||
{
|
||||
unset($whisper[$field]);
|
||||
|
||||
if (isset($whisper['segment']) && is_array($whisper['segment'])) {
|
||||
unset($whisper['segment'][$field]);
|
||||
}
|
||||
|
||||
if (isset($whisper['segments']) && is_array($whisper['segments'])) {
|
||||
$whisper['segments'] = array_map(function (mixed $segment) use ($field): mixed {
|
||||
if (is_array($segment)) {
|
||||
unset($segment[$field]);
|
||||
}
|
||||
|
||||
return $segment;
|
||||
}, $whisper['segments']);
|
||||
}
|
||||
|
||||
return $whisper;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ use App\Models\Recording;
|
||||
use App\Services\TranscriptionService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Queue\Attributes\FailOnTimeout;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
#[FailOnTimeout]
|
||||
class TranscribeRecording implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
@@ -20,6 +22,9 @@ class TranscribeRecording implements ShouldQueue
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*
|
||||
* Covers a hung Whisper HTTP call: the worker is killed, failed() runs,
|
||||
* and the recording is marked failed so the UI can restart.
|
||||
*/
|
||||
public int $timeout;
|
||||
|
||||
@@ -62,18 +67,19 @@ class TranscribeRecording implements ShouldQueue
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
|
||||
'transcription_error' => null,
|
||||
'transcription_verbose' => null,
|
||||
])->save();
|
||||
|
||||
$this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String();
|
||||
|
||||
$this->reportIfOwned('Preparing audio file…', 15);
|
||||
|
||||
try {
|
||||
$text = $transcription->transcribe(
|
||||
$this->recording,
|
||||
function (string $message, int $percent): void {
|
||||
$this->reportIfOwned($message, $percent);
|
||||
function (string $message, int $percent, ?string $partialTranscript = null, ?array $whisper = null): void {
|
||||
$this->reportIfOwned($message, $percent, $partialTranscript, $whisper);
|
||||
},
|
||||
fn (): bool => Recording::query()->find($this->recording->id)
|
||||
?->ownsTranscriptionRun($this->runStartedAt) ?? false,
|
||||
);
|
||||
|
||||
if (! $this->claimSuccessfulTranscript($text)) {
|
||||
@@ -129,21 +135,7 @@ class TranscribeRecording implements ShouldQueue
|
||||
$this->recording->refresh();
|
||||
|
||||
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
$this->reportIfOwned('Saving transcript…', 90);
|
||||
$this->recording->refresh();
|
||||
}
|
||||
|
||||
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
$this->recording->forceFill([
|
||||
'transcript' => $text,
|
||||
'transcription_status' => 'done',
|
||||
'transcription_progress' => 'Transcription complete',
|
||||
'transcription_percent' => 100,
|
||||
'transcription_error' => null,
|
||||
'transcribed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->recording->broadcastTranscriptionUpdated();
|
||||
$this->recording->markTranscriptionComplete($text);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -152,22 +144,16 @@ class TranscribeRecording implements ShouldQueue
|
||||
if (
|
||||
$this->recording->transcription_status === 'failed'
|
||||
&& $this->recording->matchesTranscriptionRun($this->runStartedAt)
|
||||
&& str_contains((string) $this->recording->transcription_error, 'worker stopped')
|
||||
&& (
|
||||
str_contains((string) $this->recording->transcription_error, 'worker stopped')
|
||||
|| str_contains((string) $this->recording->transcription_error, 'timed out')
|
||||
)
|
||||
) {
|
||||
Log::warning('Recovering transcript after false orphan failure', [
|
||||
'recording_id' => $this->recording->id,
|
||||
]);
|
||||
|
||||
$this->recording->forceFill([
|
||||
'transcript' => $text,
|
||||
'transcription_status' => 'done',
|
||||
'transcription_progress' => 'Transcription complete',
|
||||
'transcription_percent' => 100,
|
||||
'transcription_error' => null,
|
||||
'transcribed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->recording->broadcastTranscriptionUpdated();
|
||||
$this->recording->markTranscriptionComplete($text);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -180,12 +166,12 @@ class TranscribeRecording implements ShouldQueue
|
||||
return false;
|
||||
}
|
||||
|
||||
private function reportIfOwned(string $message, int $percent): void
|
||||
private function reportIfOwned(string $message, int $percent, ?string $partialTranscript = null, ?array $whisper = null): void
|
||||
{
|
||||
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recording->reportProgress($message, $percent);
|
||||
$this->recording->reportProgress($message, $percent, partialTranscript: $partialTranscript, whisperDelta: $whisper);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
@@ -22,8 +21,6 @@ class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public int $userId;
|
||||
|
||||
#[Url(as: 'q', history: true)]
|
||||
public string $search = '';
|
||||
|
||||
@@ -45,7 +42,6 @@ class Index extends Component
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->userId = (int) Auth::id();
|
||||
$this->normalizeSort();
|
||||
}
|
||||
|
||||
@@ -70,15 +66,6 @@ class Index extends Component
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render when any of this user's recordings broadcast a status change.
|
||||
*/
|
||||
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
|
||||
public function onTranscriptionUpdated(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function queuePending(): void
|
||||
{
|
||||
$queued = 0;
|
||||
|
||||
@@ -7,7 +7,6 @@ use Flux\Flux;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
@@ -15,8 +14,6 @@ class Show extends Component
|
||||
{
|
||||
public Recording $recording;
|
||||
|
||||
public int $userId;
|
||||
|
||||
public function mount(Recording $recording): void
|
||||
{
|
||||
Gate::authorize('view', $recording);
|
||||
@@ -25,22 +22,6 @@ class Show extends Component
|
||||
$recording->refresh();
|
||||
|
||||
$this->recording = $recording;
|
||||
$this->userId = (int) $recording->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render when this recording broadcasts a status change (same channel as the index).
|
||||
*
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
|
||||
public function onTranscriptionUpdated(array $event = []): void
|
||||
{
|
||||
if (isset($event['id']) && (int) $event['id'] !== (int) $this->recording->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recording->refresh();
|
||||
}
|
||||
|
||||
public function startTranscription(): void
|
||||
@@ -83,10 +64,6 @@ class Show extends Component
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
if ($this->recording->isTranscribing()) {
|
||||
$this->recording->refresh();
|
||||
}
|
||||
|
||||
return view('livewire.recordings.show')
|
||||
->title($this->recording->title);
|
||||
}
|
||||
|
||||
+282
-51
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
@@ -33,6 +34,7 @@ class Recording extends Model
|
||||
'file_size_bytes',
|
||||
'content_hash',
|
||||
'transcript',
|
||||
'transcription_verbose',
|
||||
'transcription_status',
|
||||
'transcription_progress',
|
||||
'transcription_percent',
|
||||
@@ -41,6 +43,7 @@ class Recording extends Model
|
||||
'transcription_driver',
|
||||
'ollama_url',
|
||||
'transcribed_at',
|
||||
'transcription_duration_seconds',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -53,8 +56,10 @@ class Recording extends Model
|
||||
'transcribed_at' => 'datetime',
|
||||
'transcription_started_at' => 'datetime',
|
||||
'duration_seconds' => 'integer',
|
||||
'transcription_duration_seconds' => 'integer',
|
||||
'file_size_bytes' => 'integer',
|
||||
'transcription_percent' => 'integer',
|
||||
'transcription_verbose' => 'array',
|
||||
'user_id' => 'integer',
|
||||
];
|
||||
}
|
||||
@@ -138,12 +143,13 @@ class Recording extends Model
|
||||
'transcription_percent' => null,
|
||||
'transcription_started_at' => now(),
|
||||
'transcription_error' => null,
|
||||
'transcription_duration_seconds' => null,
|
||||
// Keep the previous transcript until a new run succeeds.
|
||||
'transcribed_at' => $this->transcribed_at,
|
||||
]);
|
||||
|
||||
$recording = $this->fresh();
|
||||
RecordingTranscriptionUpdated::dispatch($recording);
|
||||
$this->broadcastTranscriptionUpdated();
|
||||
TranscribeRecording::dispatch($recording);
|
||||
}
|
||||
|
||||
@@ -162,6 +168,18 @@ class Recording extends Model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds since the current transcription run started.
|
||||
*/
|
||||
public function transcriptionElapsedSeconds(): ?int
|
||||
{
|
||||
if ($this->transcription_started_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(0, now()->getTimestamp() - $this->transcription_started_at->getTimestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Search title, metadata, and stored transcript text.
|
||||
*/
|
||||
@@ -228,33 +246,40 @@ class Recording extends Model
|
||||
return ($start > 0 ? '…' : '').$excerpt.(mb_strlen($transcript) > $start + mb_strlen($excerpt) ? '…' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds after which a reserved queue row is considered abandoned
|
||||
* (worker died mid-Whisper without releasing the job).
|
||||
*/
|
||||
public function transcriptionJobStaleAfterSeconds(): int
|
||||
{
|
||||
return max(120, (int) config('ai.transcription_timeout', 600) + 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a TranscribeRecording job for this recording is still on the queue.
|
||||
*
|
||||
* Reserved jobs older than the transcription timeout (+ grace) are ignored so
|
||||
* orphan recovery can unblock the UI when Whisper/the worker is wedged.
|
||||
*/
|
||||
public function hasActiveTranscriptionJob(): bool
|
||||
{
|
||||
$staleBefore = now()->timestamp - $this->transcriptionJobStaleAfterSeconds();
|
||||
|
||||
return DB::table('jobs')
|
||||
->pluck('payload')
|
||||
->contains(function (string $payload): bool {
|
||||
if (! str_contains($payload, TranscribeRecording::class)) {
|
||||
->orderBy('id')
|
||||
->get(['id', 'payload', 'reserved_at'])
|
||||
->contains(function (object $job) use ($staleBefore): bool {
|
||||
$payload = (string) $job->payload;
|
||||
|
||||
if (! str_contains($payload, 'TranscribeRecording')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($payload, true);
|
||||
$command = $data['data']['command'] ?? null;
|
||||
|
||||
if (! is_string($command)) {
|
||||
if ($job->reserved_at !== null && (int) $job->reserved_at <= $staleBefore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$job = unserialize($command);
|
||||
} catch (Throwable) {
|
||||
return $this->payloadMentionsRecording($payload);
|
||||
}
|
||||
|
||||
return $job instanceof TranscribeRecording
|
||||
&& (int) $job->recording->getKey() === (int) $this->id;
|
||||
return $this->jobPayloadBelongsToRecording($payload);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,8 +288,11 @@ class Recording extends Model
|
||||
*/
|
||||
private function payloadMentionsRecording(string $payload): bool
|
||||
{
|
||||
return (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|
||||
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"');
|
||||
// Jobs table stores JSON; the serialized command inside escapes quotes as \".
|
||||
return (bool) preg_match('/id\\\\";i:'.$this->id.';/', $payload)
|
||||
|| (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|
||||
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
|
||||
|| str_contains($payload, 'id\\\\";s:'.strlen((string) $this->id).':\\"'.$this->id.'\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,34 +360,16 @@ class Recording extends Model
|
||||
->each(function (object $job) use (&$deleted): void {
|
||||
$payload = (string) $job->payload;
|
||||
|
||||
if (! str_contains($payload, TranscribeRecording::class)) {
|
||||
if (! str_contains($payload, 'TranscribeRecording')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($payload, true);
|
||||
$command = $data['data']['command'] ?? null;
|
||||
|
||||
if (! is_string($command)) {
|
||||
if (! $this->jobPayloadBelongsToRecording($payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$queued = unserialize($command);
|
||||
} catch (Throwable) {
|
||||
if (! preg_match('/id";i:'.$this->id.';/', $payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('jobs')->where('id', $job->id)->delete();
|
||||
$deleted++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($queued instanceof TranscribeRecording && (int) $queued->recording->getKey() === (int) $this->id) {
|
||||
DB::table('jobs')->where('id', $job->id)->delete();
|
||||
$deleted++;
|
||||
}
|
||||
DB::table('jobs')->where('id', $job->id)->delete();
|
||||
$deleted++;
|
||||
});
|
||||
|
||||
$this->releaseTranscriptionUniqueLock();
|
||||
@@ -367,6 +377,36 @@ class Recording extends Model
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a jobs.payload row targets this recording.
|
||||
*/
|
||||
private function jobPayloadBelongsToRecording(string $payload): bool
|
||||
{
|
||||
$data = json_decode($payload, true);
|
||||
$command = $data['data']['command'] ?? null;
|
||||
|
||||
if (is_string($command)) {
|
||||
if (
|
||||
preg_match('/id";i:'.$this->id.';/', $command)
|
||||
|| str_contains($command, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$queued = unserialize($command);
|
||||
|
||||
if ($queued instanceof TranscribeRecording) {
|
||||
return (int) $queued->recording->getKey() === (int) $this->id;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Fall through to escaped JSON heuristics.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->payloadMentionsRecording($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop transcription: drop queued jobs and mark the run cancelled.
|
||||
*
|
||||
@@ -387,7 +427,7 @@ class Recording extends Model
|
||||
'transcription_error' => 'Stopped by user',
|
||||
])->save();
|
||||
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||
$this->broadcastTranscriptionUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,6 +440,30 @@ class Recording extends Model
|
||||
)->forceRelease();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a successful transcript and how long the run took.
|
||||
*/
|
||||
public function markTranscriptionComplete(string $text): void
|
||||
{
|
||||
$finishedAt = now();
|
||||
$startedAt = $this->transcription_started_at;
|
||||
$durationSeconds = $startedAt === null
|
||||
? null
|
||||
: max(0, $finishedAt->getTimestamp() - $startedAt->getTimestamp());
|
||||
|
||||
$this->forceFill([
|
||||
'transcript' => $text,
|
||||
'transcription_status' => 'done',
|
||||
'transcription_progress' => 'Transcription complete',
|
||||
'transcription_percent' => 100,
|
||||
'transcription_error' => null,
|
||||
'transcribed_at' => $finishedAt,
|
||||
'transcription_duration_seconds' => $durationSeconds,
|
||||
])->save();
|
||||
|
||||
$this->broadcastTranscriptionUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transcription as failed and unblock the UI.
|
||||
*/
|
||||
@@ -416,11 +480,11 @@ class Recording extends Model
|
||||
'transcription_error' => $message,
|
||||
])->save();
|
||||
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||
$this->broadcastTranscriptionUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a stuck transcription if the queue job is gone.
|
||||
* Recover a stuck transcription if the queue job is gone or a reservation is stale.
|
||||
*/
|
||||
public function recoverOrphanedTranscription(): bool
|
||||
{
|
||||
@@ -428,8 +492,11 @@ class Recording extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drop abandoned reserved rows so a restart can enqueue cleanly.
|
||||
$this->discardQueuedTranscriptionJobs();
|
||||
|
||||
$this->markTranscriptionFailed(
|
||||
'Transcription worker stopped before finishing. Start transcription again.',
|
||||
'Transcription timed out or the worker stopped before finishing. Start transcription again.',
|
||||
);
|
||||
|
||||
return true;
|
||||
@@ -437,25 +504,184 @@ class Recording extends Model
|
||||
|
||||
/**
|
||||
* Update the live progress fields shown in the UI.
|
||||
*
|
||||
* @param array<string, mixed>|null $whisperDelta
|
||||
*/
|
||||
public function reportProgress(string $message, int $percent, string $status = 'processing'): void
|
||||
public function reportProgress(string $message, int $percent, string $status = 'processing', ?string $partialTranscript = null, ?array $whisperDelta = null): void
|
||||
{
|
||||
$this->forceFill([
|
||||
$diff = $this->transcriptBroadcastDiff($partialTranscript);
|
||||
|
||||
$attributes = [
|
||||
'transcription_status' => $status,
|
||||
'transcription_progress' => $message,
|
||||
'transcription_percent' => max(0, min(100, $percent)),
|
||||
'transcription_error' => null,
|
||||
])->save();
|
||||
];
|
||||
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||
if ($partialTranscript !== null) {
|
||||
$attributes['transcript'] = $partialTranscript;
|
||||
}
|
||||
|
||||
if ($whisperDelta !== null) {
|
||||
$attributes['transcription_verbose'] = $this->mergeWhisperVerbose($whisperDelta);
|
||||
}
|
||||
|
||||
$this->forceFill($attributes)->save();
|
||||
|
||||
$this->broadcastTranscriptionUpdated($diff['delta'], $diff['replace'], $whisperDelta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the current transcription status to connected browsers.
|
||||
*
|
||||
* @param array<string, mixed>|null $whisperDelta
|
||||
*/
|
||||
public function broadcastTranscriptionUpdated(): void
|
||||
public function broadcastTranscriptionUpdated(?string $transcriptDelta = null, bool $transcriptReplace = false, ?array $whisperDelta = null): void
|
||||
{
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this);
|
||||
try {
|
||||
RecordingTranscriptionUpdated::dispatch(
|
||||
$this->fresh() ?? $this,
|
||||
$transcriptDelta,
|
||||
$transcriptReplace,
|
||||
$whisperDelta,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to broadcast transcription status', [
|
||||
'recording_id' => $this->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental text to push over Reverb instead of the full transcript.
|
||||
*
|
||||
* @return array{delta: ?string, replace: bool}
|
||||
*/
|
||||
public function transcriptBroadcastDiff(?string $next): array
|
||||
{
|
||||
if ($next === null) {
|
||||
return ['delta' => null, 'replace' => false];
|
||||
}
|
||||
|
||||
$previous = (string) $this->transcript;
|
||||
|
||||
if ($previous === '' || ! str_starts_with($next, $previous)) {
|
||||
return ['delta' => $next, 'replace' => true];
|
||||
}
|
||||
|
||||
$delta = substr($next, strlen($previous));
|
||||
|
||||
return ['delta' => $delta === '' ? null : $delta, 'replace' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a streamed Whisper chunk into the stored verbose_json snapshot.
|
||||
*
|
||||
* @param array<string, mixed> $delta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function mergeWhisperVerbose(array $delta): array
|
||||
{
|
||||
$verbose = is_array($this->transcription_verbose) ? $this->transcription_verbose : [];
|
||||
|
||||
if (isset($delta['language']) && is_string($delta['language'])) {
|
||||
$verbose['language'] = $delta['language'];
|
||||
}
|
||||
|
||||
if (isset($delta['duration']) && is_numeric($delta['duration'])) {
|
||||
$verbose['duration'] = (float) $delta['duration'];
|
||||
}
|
||||
|
||||
$segments = is_array($verbose['segments'] ?? null) ? $verbose['segments'] : [];
|
||||
|
||||
if (isset($delta['segment']) && is_array($delta['segment'])) {
|
||||
$segments = $this->appendVerboseSegment($segments, $delta['segment']);
|
||||
}
|
||||
|
||||
if (isset($delta['segments']) && is_array($delta['segments'])) {
|
||||
$incoming = [];
|
||||
|
||||
foreach ($delta['segments'] as $segment) {
|
||||
if (is_array($segment)) {
|
||||
$incoming[] = $segment;
|
||||
}
|
||||
}
|
||||
|
||||
if ($incoming !== []) {
|
||||
if (isset($delta['segment'])) {
|
||||
foreach ($incoming as $segment) {
|
||||
$segments = $this->appendVerboseSegment($segments, $segment);
|
||||
}
|
||||
} else {
|
||||
$segments = $incoming;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($delta['words']) && is_array($delta['words']) && $delta['words'] !== []) {
|
||||
$last = $segments === [] ? null : count($segments) - 1;
|
||||
|
||||
if ($last === null) {
|
||||
$segments[] = ['words' => $delta['words']];
|
||||
} else {
|
||||
$existing = is_array($segments[$last]['words'] ?? null) ? $segments[$last]['words'] : [];
|
||||
$segments[$last]['words'] = array_merge($existing, $delta['words']);
|
||||
}
|
||||
}
|
||||
|
||||
$logprobs = is_array($verbose['logprobs'] ?? null) ? $verbose['logprobs'] : [];
|
||||
|
||||
if (isset($delta['logprobs']) && is_array($delta['logprobs'])) {
|
||||
foreach ($delta['logprobs'] as $row) {
|
||||
if (is_array($row)) {
|
||||
$logprobs[] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($segments !== []) {
|
||||
$verbose['segments'] = $segments;
|
||||
}
|
||||
|
||||
if ($logprobs !== []) {
|
||||
$verbose['logprobs'] = $logprobs;
|
||||
}
|
||||
|
||||
return $verbose;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $segments
|
||||
* @param array<string, mixed> $segment
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function appendVerboseSegment(array $segments, array $segment): array
|
||||
{
|
||||
$key = $this->verboseSegmentKey($segment);
|
||||
|
||||
foreach ($segments as $existing) {
|
||||
if ($this->verboseSegmentKey($existing) === $key) {
|
||||
return $segments;
|
||||
}
|
||||
}
|
||||
|
||||
$segments[] = $segment;
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $segment
|
||||
*/
|
||||
private function verboseSegmentKey(array $segment): string
|
||||
{
|
||||
return implode('|', [
|
||||
$segment['id'] ?? '',
|
||||
$segment['start'] ?? '',
|
||||
$segment['end'] ?? '',
|
||||
$segment['text'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -504,7 +730,7 @@ class Recording extends Model
|
||||
public function transcriptionStatusPayload(): array
|
||||
{
|
||||
$startedAt = $this->transcription_started_at;
|
||||
$elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null;
|
||||
$elapsed = $this->transcriptionElapsedSeconds();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
@@ -519,9 +745,14 @@ class Recording extends Model
|
||||
'elapsed_seconds' => $elapsed,
|
||||
'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed),
|
||||
'duration_seconds' => $this->duration_seconds,
|
||||
'transcription_duration_seconds' => $this->transcription_duration_seconds,
|
||||
'transcription_duration_human' => $this->transcription_duration_seconds === null
|
||||
? null
|
||||
: $this->formatElapsed($this->transcription_duration_seconds),
|
||||
'is_active' => $this->isTranscribing(),
|
||||
'has_transcript' => filled($this->transcript),
|
||||
'transcript' => $this->transcript,
|
||||
'whisper' => $this->transcription_verbose,
|
||||
'transcribed_at' => $this->transcribed_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,29 +4,251 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Closure;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Ai\Transcription;
|
||||
use RuntimeException;
|
||||
|
||||
class TranscriptionService
|
||||
{
|
||||
public function __construct(private WhisperTranscriptionStream $stream) {}
|
||||
|
||||
/**
|
||||
* Transcribe a recording with the local faster-whisper server.
|
||||
*
|
||||
* @param (Closure(string, int): void)|null $onProgress
|
||||
* @param (Closure(string, int, ?string, ?array): void)|null $onProgress
|
||||
* @param (Closure(): bool)|null $shouldContinue
|
||||
*/
|
||||
public function transcribe(Recording $recording, ?Closure $onProgress = null): string
|
||||
public function transcribe(Recording $recording, ?Closure $onProgress = null, ?Closure $shouldContinue = null): string
|
||||
{
|
||||
$report = $onProgress ?? static fn (string $message, int $percent) => null;
|
||||
$report = $onProgress ?? static fn (string $message, int $percent, ?string $partial = null, ?array $whisper = null) => null;
|
||||
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
||||
|
||||
$report('Connecting to local faster-whisper server…', 30);
|
||||
$report("Transcribing locally with {$model} (audio stays on this machine)…", 50);
|
||||
$report("Transcribing locally with {$model} (audio stays on this machine)…", 0);
|
||||
|
||||
$transcript = Transcription::fromStorage($recording->file_path)
|
||||
->timeout((int) config('ai.transcription_timeout', 600))
|
||||
->generate('local-whisper', $model);
|
||||
|
||||
$report('Received transcript from local Whisper…', 85);
|
||||
if (Transcription::isFaked()) {
|
||||
$transcript = Transcription::fromStorage($recording->file_path)
|
||||
->timeout((int) config('ai.transcription_timeout', 600))
|
||||
->generate('local-whisper', $model);
|
||||
} else {
|
||||
$transcript = $this->transcribeViaLocalWhisper($recording, $report, $model, $shouldContinue);
|
||||
}
|
||||
|
||||
return (string) $transcript;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call local Whisper, streaming SSE when the server supports it.
|
||||
*
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
* @param (Closure(): bool)|null $shouldContinue
|
||||
*/
|
||||
private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): string
|
||||
{
|
||||
$path = $recording->absolutePath();
|
||||
|
||||
if (! is_readable($path)) {
|
||||
throw new RuntimeException('Recording audio file is not readable.');
|
||||
}
|
||||
|
||||
$message = "Transcribing locally with {$model} (audio stays on this machine)…";
|
||||
$filename = $recording->original_filename ?: basename($path);
|
||||
|
||||
$response = $this->localWhisperRequest($filename, $path)
|
||||
->withOptions(['stream' => true])
|
||||
->post('audio/transcriptions', [
|
||||
'model' => $model,
|
||||
'response_format' => 'verbose_json',
|
||||
'stream' => 'true',
|
||||
'without_timestamps' => 'false',
|
||||
'timestamp_granularities[]' => 'word',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(
|
||||
'Local transcription failed (HTTP '.$response->status().'): '.$response->body()
|
||||
);
|
||||
}
|
||||
|
||||
$contentType = strtolower((string) $response->header('Content-Type'));
|
||||
|
||||
if (! str_contains($contentType, 'text/event-stream')) {
|
||||
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
|
||||
'recording_id' => $recording->id,
|
||||
'content_type' => $contentType,
|
||||
]);
|
||||
|
||||
return $this->extractTranscriptText($response, $report, $message);
|
||||
}
|
||||
|
||||
return $this->consumeWhisperStream($response, $report, $message, $recording, $shouldContinue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
* @param (Closure(): bool)|null $shouldContinue
|
||||
*/
|
||||
private function consumeWhisperStream(
|
||||
Response $response,
|
||||
Closure $report,
|
||||
string $message,
|
||||
Recording $recording,
|
||||
?Closure $shouldContinue,
|
||||
): string {
|
||||
$body = $response->toPsrResponse()->getBody();
|
||||
$buffer = '';
|
||||
$accumulated = '';
|
||||
$lastPercent = 0;
|
||||
$idleReads = 0;
|
||||
|
||||
try {
|
||||
while (! $body->eof()) {
|
||||
if ($shouldContinue !== null && ! $shouldContinue()) {
|
||||
$body->close();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$chunk = $body->read(8192);
|
||||
|
||||
if ($chunk === '') {
|
||||
$idleReads++;
|
||||
|
||||
if ($idleReads >= 40) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(50_000);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$idleReads = 0;
|
||||
$buffer .= $chunk;
|
||||
|
||||
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||
$payload,
|
||||
$accumulated,
|
||||
$lastPercent,
|
||||
$recording,
|
||||
$report,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||
$payload,
|
||||
$accumulated,
|
||||
$lastPercent,
|
||||
$recording,
|
||||
$report,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
$response->close();
|
||||
}
|
||||
|
||||
if ($accumulated === '') {
|
||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||
}
|
||||
|
||||
return $accumulated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
* @return array{0: string, 1: int}
|
||||
*/
|
||||
private function ingestEvent(
|
||||
string $payload,
|
||||
string $accumulated,
|
||||
int $lastPercent,
|
||||
Recording $recording,
|
||||
Closure $report,
|
||||
string $message,
|
||||
): array {
|
||||
$event = $this->stream->parseEvent($payload);
|
||||
|
||||
if ($event === null) {
|
||||
return [$accumulated, $lastPercent];
|
||||
}
|
||||
|
||||
$accumulated = $this->stream->applyEvent($accumulated, $event);
|
||||
$whisper = $event['whisper'] ?? [];
|
||||
|
||||
if ($accumulated === '' && $whisper === []) {
|
||||
return [$accumulated, $lastPercent];
|
||||
}
|
||||
|
||||
$lastPercent = $this->percentForEvent($event, $recording, $lastPercent);
|
||||
|
||||
$report(
|
||||
$message,
|
||||
$lastPercent,
|
||||
$accumulated === '' ? null : $accumulated,
|
||||
$whisper === [] ? null : $whisper,
|
||||
);
|
||||
|
||||
return [$accumulated, $lastPercent];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
|
||||
*/
|
||||
private function percentForEvent(array $event, Recording $recording, int $lastPercent): int
|
||||
{
|
||||
$duration = $recording->duration_seconds;
|
||||
$end = $event['end'] ?? null;
|
||||
|
||||
if ($end === null || $duration === null || $duration <= 0) {
|
||||
return $lastPercent;
|
||||
}
|
||||
|
||||
return (int) min(99, max($lastPercent, round(100 * $end / $duration)));
|
||||
}
|
||||
|
||||
private function localWhisperRequest(string $filename, string $path): PendingRequest
|
||||
{
|
||||
$config = config('ai.providers.local-whisper');
|
||||
$baseUrl = rtrim((string) ($config['url'] ?? 'http://127.0.0.1:8090/v1'), '/');
|
||||
$timeout = (int) config('ai.transcription_timeout', 600);
|
||||
|
||||
return Http::baseUrl($baseUrl)
|
||||
->withHeaders(['Authorization' => 'Bearer '.($config['key'] ?? 'not-needed')])
|
||||
->timeout($timeout)
|
||||
->connectTimeout(10)
|
||||
->attach('file', fopen($path, 'r'), $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
*/
|
||||
private function extractTranscriptText(Response $response, ?Closure $report = null, string $message = ''): string
|
||||
{
|
||||
$json = $response->json();
|
||||
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||
}
|
||||
|
||||
$text = $json['text'] ?? null;
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||
}
|
||||
|
||||
$whisper = $this->stream->extractWhisperMeta($json);
|
||||
|
||||
if ($report !== null && $whisper !== []) {
|
||||
$report($message, 99, $text, $whisper);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class WhisperTranscriptionStream
|
||||
{
|
||||
/**
|
||||
* Pull complete SSE `data:` payloads out of a rolling buffer.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function extractPayloads(string &$buffer): array
|
||||
{
|
||||
$frames = explode("\n\n", str_replace("\r\n", "\n", $buffer));
|
||||
$buffer = array_pop($frames) ?? '';
|
||||
$payloads = [];
|
||||
|
||||
foreach ($frames as $frame) {
|
||||
foreach ($this->dataLines($frame) as $payload) {
|
||||
if ($payload === '[DONE]' || $payload === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payloads[] = $payload;
|
||||
}
|
||||
}
|
||||
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a trailing incomplete frame at end-of-stream.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function flushBuffer(string &$buffer): array
|
||||
{
|
||||
$trimmed = trim($buffer);
|
||||
|
||||
if ($trimmed === '') {
|
||||
$buffer = '';
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$buffer .= "\n\n";
|
||||
|
||||
return $this->extractPayloads($buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one SSE JSON payload into append/replace/end/done/whisper fields.
|
||||
*
|
||||
* @return array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper: array<string, mixed>}|null
|
||||
*/
|
||||
public function parseEvent(string $json): ?array
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $data['type'] ?? null;
|
||||
$whisper = $this->extractWhisperMeta($data);
|
||||
$end = $this->latestAudioEnd($data, $whisper);
|
||||
|
||||
if ($type === 'transcript.text.delta') {
|
||||
$delta = $data['delta'] ?? '';
|
||||
|
||||
if ((! is_string($delta) || $delta === '') && $whisper === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'append' => is_string($delta) && $delta !== '' ? $delta : null,
|
||||
'replace' => null,
|
||||
'end' => $end,
|
||||
'done' => false,
|
||||
'legacy' => false,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'transcript.text.done') {
|
||||
$text = $data['text'] ?? '';
|
||||
|
||||
return [
|
||||
'append' => null,
|
||||
'replace' => is_string($text) ? $text : '',
|
||||
'end' => $end,
|
||||
'done' => true,
|
||||
'legacy' => false,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->isLiveSegmentEvent($type, $data)) {
|
||||
return [
|
||||
'append' => $data['text'],
|
||||
'replace' => null,
|
||||
'end' => $end,
|
||||
'done' => false,
|
||||
'legacy' => true,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($data['segments']) && is_array($data['segments'])) {
|
||||
$text = $data['text'] ?? '';
|
||||
|
||||
if ((! is_string($text) || $text === '') && $whisper === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'append' => null,
|
||||
'replace' => is_string($text) && $text !== '' ? $text : null,
|
||||
'end' => $end,
|
||||
'done' => true,
|
||||
'legacy' => false,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
if ($whisper !== []) {
|
||||
return [
|
||||
'append' => null,
|
||||
'replace' => null,
|
||||
'end' => $end,
|
||||
'done' => false,
|
||||
'legacy' => false,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a parsed event to the accumulated transcript.
|
||||
*
|
||||
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
|
||||
*/
|
||||
public function applyEvent(string $accumulated, array $event): string
|
||||
{
|
||||
if (is_string($event['replace'])) {
|
||||
return $event['replace'];
|
||||
}
|
||||
|
||||
$chunk = $event['append'] ?? '';
|
||||
|
||||
if ($chunk === '') {
|
||||
return $accumulated;
|
||||
}
|
||||
|
||||
if ($event['legacy']) {
|
||||
$chunk = trim($chunk);
|
||||
|
||||
if ($accumulated === '') {
|
||||
return $chunk;
|
||||
}
|
||||
|
||||
return rtrim($accumulated).' '.$chunk;
|
||||
}
|
||||
|
||||
return $accumulated.$chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function extractWhisperMeta(array $data): array
|
||||
{
|
||||
$meta = [];
|
||||
|
||||
if (isset($data['language']) && is_string($data['language']) && $data['language'] !== '') {
|
||||
$meta['language'] = $data['language'];
|
||||
}
|
||||
|
||||
if (is_numeric($data['duration'] ?? null)) {
|
||||
$meta['duration'] = (float) $data['duration'];
|
||||
}
|
||||
|
||||
if (isset($data['logprobs']) && is_array($data['logprobs'])) {
|
||||
$logprobs = $this->normalizeLogprobs($data['logprobs']);
|
||||
|
||||
if ($logprobs !== []) {
|
||||
$meta['logprobs'] = $logprobs;
|
||||
}
|
||||
}
|
||||
|
||||
$segment = $this->normalizeSegment($data);
|
||||
|
||||
if ($segment !== null && ($data['type'] ?? null) !== 'transcript.text.delta') {
|
||||
$meta['segment'] = $segment;
|
||||
}
|
||||
|
||||
if (isset($data['segments']) && is_array($data['segments'])) {
|
||||
$segments = [];
|
||||
|
||||
foreach ($data['segments'] as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeSegment($row);
|
||||
|
||||
if ($normalized !== null) {
|
||||
$segments[] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ($segments !== [] && ! isset($meta['segment'])) {
|
||||
$meta['segments'] = $segments;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['words']) && is_array($data['words']) && ! isset($meta['segment']) && ! isset($meta['segments'])) {
|
||||
$words = $this->normalizeWords($data['words']);
|
||||
|
||||
if ($words !== []) {
|
||||
$meta['words'] = $words;
|
||||
}
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function normalizeSegment(array $data): ?array
|
||||
{
|
||||
$hasDetail = isset($data['start'])
|
||||
|| isset($data['end'])
|
||||
|| isset($data['words'])
|
||||
|| isset($data['avg_logprob'])
|
||||
|| isset($data['tokens'])
|
||||
|| isset($data['no_speech_prob'])
|
||||
|| array_key_exists('id', $data);
|
||||
|
||||
if (! $hasDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = $data['text'] ?? null;
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$segment = [
|
||||
'id' => is_numeric($data['id'] ?? null) ? (int) $data['id'] : null,
|
||||
'seek' => is_numeric($data['seek'] ?? null) ? (int) $data['seek'] : null,
|
||||
'start' => $this->nullableFloat($data['start'] ?? null),
|
||||
'end' => $this->nullableFloat($data['end'] ?? null),
|
||||
'text' => $text,
|
||||
'tokens' => $this->normalizeTokens($data['tokens'] ?? null),
|
||||
'temperature' => $this->nullableFloat($data['temperature'] ?? null),
|
||||
'avg_logprob' => $this->nullableFloat($data['avg_logprob'] ?? null),
|
||||
'compression_ratio' => $this->nullableFloat($data['compression_ratio'] ?? null),
|
||||
'no_speech_prob' => $this->nullableFloat($data['no_speech_prob'] ?? null),
|
||||
'words' => $this->normalizeWords($data['words'] ?? null),
|
||||
];
|
||||
|
||||
return array_filter($segment, fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
/**
|
||||
* One streamed Whisper chunk (not a finished verbose_json document).
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function isLiveSegmentEvent(mixed $type, array $data): bool
|
||||
{
|
||||
$text = $data['text'] ?? null;
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($type === 'segment') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($type !== null && $type !== '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($data['start']) || isset($data['end'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! (isset($data['segments']) && is_array($data['segments']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function dataLines(string $frame): array
|
||||
{
|
||||
$payloads = [];
|
||||
|
||||
foreach (explode("\n", $frame) as $line) {
|
||||
if (str_starts_with($line, 'data:')) {
|
||||
$payloads[] = ltrim(substr($line, 5));
|
||||
}
|
||||
}
|
||||
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest media timestamp in this event (segment/word `end`). Never file `duration`.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $whisper
|
||||
*/
|
||||
public function latestAudioEnd(array $data, array $whisper): ?float
|
||||
{
|
||||
$ends = [];
|
||||
|
||||
$direct = $this->nullableFloat($data['end'] ?? null);
|
||||
|
||||
if ($direct !== null) {
|
||||
$ends[] = $direct;
|
||||
}
|
||||
|
||||
$this->collectAudioEnds($ends, $whisper);
|
||||
|
||||
if (isset($data['words']) && is_array($data['words'])) {
|
||||
$this->collectAudioEnds($ends, ['words' => $data['words']]);
|
||||
}
|
||||
|
||||
return $ends === [] ? null : max($ends);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $ends
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function collectAudioEnds(array &$ends, array $node): void
|
||||
{
|
||||
$end = $this->nullableFloat($node['end'] ?? null);
|
||||
|
||||
if ($end !== null) {
|
||||
$ends[] = $end;
|
||||
}
|
||||
|
||||
foreach (['words', 'segments'] as $key) {
|
||||
if (! isset($node[$key]) || ! is_array($node[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($node[$key] as $child) {
|
||||
if (is_array($child)) {
|
||||
$this->collectAudioEnds($ends, $child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($node['segment']) && is_array($node['segment'])) {
|
||||
$this->collectAudioEnds($ends, $node['segment']);
|
||||
}
|
||||
}
|
||||
|
||||
private function nullableFloat(mixed $value): ?float
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>|null
|
||||
*/
|
||||
private function normalizeTokens(mixed $tokens): ?array
|
||||
{
|
||||
if (! is_array($tokens) || $tokens === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (is_numeric($token)) {
|
||||
$normalized[] = (int) $token;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized === [] ? null : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{word: string, start: ?float, end: ?float, probability: ?float}>|null
|
||||
*/
|
||||
private function normalizeWords(mixed $words): ?array
|
||||
{
|
||||
if (! is_array($words) || $words === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (! is_array($word)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = $word['word'] ?? $word['text'] ?? null;
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = array_filter([
|
||||
'word' => $text,
|
||||
'start' => $this->nullableFloat($word['start'] ?? null),
|
||||
'end' => $this->nullableFloat($word['end'] ?? null),
|
||||
'probability' => $this->nullableFloat($word['probability'] ?? $word['prob'] ?? null),
|
||||
], fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
|
||||
return $normalized === [] ? null : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{token: ?string, logprob: ?float}>
|
||||
*/
|
||||
private function normalizeLogprobs(array $logprobs): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($logprobs as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token = $row['token'] ?? $row['bytes'] ?? null;
|
||||
$token = is_string($token) ? $token : null;
|
||||
$logprob = $this->nullableFloat($row['logprob'] ?? $row['avg_logprob'] ?? null);
|
||||
|
||||
if ($token === null && $logprob === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = array_filter([
|
||||
'token' => $token,
|
||||
'logprob' => $logprob,
|
||||
], fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -33,24 +33,6 @@ class DiskSpaceBar extends Component
|
||||
return $this->disk !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress fill color based on remaining free space.
|
||||
*/
|
||||
public function barColor(): string
|
||||
{
|
||||
$freePercent = $this->disk['free_percent'] ?? 100;
|
||||
|
||||
if ($freePercent <= 5) {
|
||||
return 'bg-red-600';
|
||||
}
|
||||
|
||||
if ($freePercent <= 15) {
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
|
||||
return 'bg-teal-600';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"agents": [
|
||||
"claude_code",
|
||||
"cursor"
|
||||
],
|
||||
"cloud": false,
|
||||
"guidelines": true,
|
||||
"mcp": true,
|
||||
@@ -7,7 +11,11 @@
|
||||
"skills": [
|
||||
"infer-conventions",
|
||||
"ai-sdk-development",
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"fluxui-development",
|
||||
"livewire-development",
|
||||
"echo-development",
|
||||
"tailwindcss-development"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// CPM/Caddy terminates TLS; trust forwarded proto/host so asset() URLs stay https.
|
||||
$middleware->trustProxies(
|
||||
at: '*',
|
||||
headers: Request::HEADER_X_FORWARDED_FOR
|
||||
| Request::HEADER_X_FORWARDED_HOST
|
||||
| Request::HEADER_X_FORWARDED_PORT
|
||||
| Request::HEADER_X_FORWARDED_PROTO
|
||||
| Request::HEADER_X_FORWARDED_PREFIX,
|
||||
);
|
||||
|
||||
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||
$middleware->redirectUsersTo(fn () => route('recordings.index'));
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# z00 overlay for AndyTranscribe (behind Caddy Proxy Manager).
|
||||
# Instance .env supplies PUBLIC_APP_URL, REVERB_PUBLIC_HOST, CADDY_NETWORK, CONTAINER_PREFIX.
|
||||
services:
|
||||
app:
|
||||
networks:
|
||||
- default
|
||||
- caddy
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
|
||||
TRUSTED_PROXIES: "*"
|
||||
REVERB_HOST: reverb
|
||||
REVERB_PORT: "8080"
|
||||
REVERB_SCHEME: http
|
||||
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
|
||||
REVERB_PUBLIC_PORT: "443"
|
||||
REVERB_PUBLIC_SCHEME: https
|
||||
reverb:
|
||||
networks:
|
||||
- default
|
||||
- caddy
|
||||
environment:
|
||||
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
|
||||
REVERB_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
|
||||
REVERB_PORT: "443"
|
||||
REVERB_SCHEME: https
|
||||
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
|
||||
REVERB_PUBLIC_PORT: "443"
|
||||
REVERB_PUBLIC_SCHEME: https
|
||||
queue:
|
||||
networks:
|
||||
- default
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
|
||||
REVERB_HOST: reverb
|
||||
REVERB_PORT: "8080"
|
||||
REVERB_SCHEME: http
|
||||
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
|
||||
REVERB_PUBLIC_PORT: "443"
|
||||
REVERB_PUBLIC_SCHEME: https
|
||||
whisper:
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
name: ${CADDY_NETWORK:-caddy-proxy-manager-test_caddy-test-network}
|
||||
@@ -18,6 +18,7 @@
|
||||
"symfony/polyfill-iconv": "^1.37"
|
||||
},
|
||||
"require-dev": {
|
||||
"albertoarena/laravel-truss": "^1.8",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^2.5",
|
||||
"laravel/pail": "^1.2.5",
|
||||
|
||||
Generated
+143
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d2a98af0564164e3228a1b7712e36e87",
|
||||
"content-hash": "eb8ccdae5381401eaa99d07ff82ce500",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -8941,6 +8941,87 @@
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "albertoarena/laravel-truss",
|
||||
"version": "v1.8.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/albertoarena/laravel-truss.git",
|
||||
"reference": "d8750756f5f239066c7f0d6e40b0eeb7e7e435bf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/albertoarena/laravel-truss/zipball/d8750756f5f239066c7f0d6e40b0eeb7e7e435bf",
|
||||
"reference": "d8750756f5f239066c7f0d6e40b0eeb7e7e435bf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/contracts": "^12.0 || ^13.0",
|
||||
"illuminate/support": "^12.0 || ^13.0",
|
||||
"php": "^8.3",
|
||||
"spatie/laravel-package-tools": "^1.16"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/mcp": "^0.9",
|
||||
"laravel/pint": "^1.18",
|
||||
"orchestra/testbench": "^10.0 || ^11.0",
|
||||
"pestphp/pest": "^3.5"
|
||||
},
|
||||
"suggest": {
|
||||
"laravel/mcp": "Enables the optional read-only, structure-only Truss MCP server (Truss as AI context). Opt in with `composer require laravel/mcp`. Needs Laravel >= 12.41.1 or 13."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"AlbertoArena\\Truss\\TrussServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"AlbertoArena\\Truss\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alberto Arena",
|
||||
"email": "arena.alberto@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A live database structure viewer for Laravel. Renders your schema (never data) as a scrollable, zoomable ER diagram.",
|
||||
"homepage": "https://trussphp.com",
|
||||
"keywords": [
|
||||
"DBML",
|
||||
"data-dictionary",
|
||||
"database",
|
||||
"database-diagram",
|
||||
"er-diagram",
|
||||
"erd",
|
||||
"introspection",
|
||||
"laravel",
|
||||
"mermaid",
|
||||
"migrations",
|
||||
"schema"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://trussphp.com",
|
||||
"issues": "https://github.com/albertoarena/laravel-truss/issues",
|
||||
"source": "https://github.com/albertoarena/laravel-truss"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://ko-fi.com/albertoarena",
|
||||
"type": "ko-fi"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-12T14:11:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/semver",
|
||||
"version": "3.4.4",
|
||||
@@ -11403,6 +11484,67 @@
|
||||
],
|
||||
"time": "2025-02-07T05:00:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.93.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-package-tools.git",
|
||||
"reference": "d5552849801f2642aea710557463234b59ef65eb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
|
||||
"reference": "d5552849801f2642aea710557463234b59ef65eb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.5",
|
||||
"orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
|
||||
"pestphp/pest": "^2.1|^3.1|^4.0",
|
||||
"phpunit/php-code-coverage": "^10.0|^11.0|^12.0",
|
||||
"phpunit/phpunit": "^10.5|^11.5|^12.5",
|
||||
"spatie/pest-plugin-test-time": "^2.2|^3.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\LaravelPackageTools\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Tools for creating Laravel packages",
|
||||
"homepage": "https://github.com/spatie/laravel-package-tools",
|
||||
"keywords": [
|
||||
"laravel-package-tools",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
||||
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-19T14:06:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "staabm/side-effects-detector",
|
||||
"version": "1.0.5",
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ return [
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => 5000,
|
||||
// Wait longer under concurrent writers (queue + web on one SQLite file).
|
||||
'busy_timeout' => (int) env('DB_BUSY_TIMEOUT', 30000),
|
||||
'journal_mode' => 'WAL',
|
||||
'synchronous' => 'NORMAL',
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
|
||||
@@ -15,6 +15,24 @@ return [
|
||||
|
||||
'default' => env('REVERB_SERVER', 'reverb'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Browser (Echo) connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Public WebSocket host/key rendered into HTML so one image can serve
|
||||
| production and staging. Server-side publish still uses REVERB_HOST
|
||||
| (Docker DNS "reverb" behind the z00 overlay).
|
||||
|
|
||||
*/
|
||||
|
||||
'client' => [
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'host' => env('REVERB_PUBLIC_HOST', env('REVERB_HOST')),
|
||||
'port' => env('REVERB_PUBLIC_PORT', env('REVERB_PORT', 443)),
|
||||
'scheme' => env('REVERB_PUBLIC_SCHEME', env('REVERB_SCHEME', 'https')),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Servers
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Configuration for albertoarena/laravel-truss.
|
||||
// This is the single source of truth for Truss's behaviour. Authorization is a
|
||||
// fixed `viewTruss` gate the host app defines — the ability name is not set here.
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Route prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| URL prefix under which the index page and the JSON schema endpoint are
|
||||
| registered, e.g. "truss" → GET /truss and GET /truss/api/schema.
|
||||
|
|
||||
*/
|
||||
|
||||
'route_prefix' => env('TRUSS_ROUTE_PREFIX', 'truss'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Enabled
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Global on/off switch. Defaults to enabled only in the local environment.
|
||||
| Authorization is enforced separately by the fixed `viewTruss` gate.
|
||||
|
|
||||
*/
|
||||
|
||||
'enabled' => env('TRUSS_ENABLED', env('APP_ENV', 'production') === 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The middleware stack applied to both Truss routes. Its job is to establish
|
||||
| the auth context (session, cookies, the authenticated user) so the
|
||||
| `viewTruss` gate can identify who is viewing — without it, the gate sees no
|
||||
| user and denies everyone in non-local environments. The default `web` group
|
||||
| covers session-based auth; swap it for a custom guard/Sanctum stack if your
|
||||
| app authenticates differently.
|
||||
|
|
||||
| The fixed `viewTruss` authorization check is always appended after this and
|
||||
| cannot be configured away — this list controls the auth *context*, not
|
||||
| whether authorization runs.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authorization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Truss is gated by the fixed `viewTruss` gate (the ability name is not
|
||||
| configurable). In non-local environments the shipped default gate admits
|
||||
| only the emails listed here — the zero-code path for "let these admins in".
|
||||
| Set them via TRUSS_ALLOWED_EMAILS as a comma-separated list, e.g.
|
||||
| TRUSS_ALLOWED_EMAILS="ada@example.com,grace@example.com".
|
||||
|
|
||||
| The list is ignored in local (the gate is not consulted there) and ignored
|
||||
| entirely if the host app defines its own `viewTruss` gate (e.g. a role
|
||||
| check). An empty list fails closed: no one may view in non-local until you
|
||||
| either add emails here or override the gate.
|
||||
|
|
||||
*/
|
||||
|
||||
'authorization' => [
|
||||
'allowed_emails' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('TRUSS_ALLOWED_EMAILS', '')),
|
||||
))),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The schema snapshot is derived, disposable data cached via Laravel's Cache
|
||||
| facade, keyed per connection. `ttl` is in seconds.
|
||||
|
|
||||
*/
|
||||
|
||||
'cache' => [
|
||||
'ttl' => (int) env('TRUSS_CACHE_TTL', 3600),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Which database connections are visualizable, and any per-connection
|
||||
| overrides. When left empty, Truss uses the application's default
|
||||
| connection (config('database.default')).
|
||||
|
|
||||
| Example:
|
||||
| 'mysql' => ['excluded_tables' => ['legacy_import']],
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
//
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Excluded tables
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Tables hidden from the diagram by default (framework/infrastructure noise).
|
||||
| Applied server-side: excluded tables never appear in the API response.
|
||||
|
|
||||
*/
|
||||
|
||||
'excluded_tables' => [
|
||||
'migrations',
|
||||
'password_reset_tokens',
|
||||
'sessions',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Diagram
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Styling options passed through to the Mermaid theme, plus the default
|
||||
| column-type label mode:
|
||||
| 'native' → the full DB type (varchar(255), bigint unsigned) [default]
|
||||
| 'laravel' → a best-effort Laravel-style short label (string, integer)
|
||||
| The mode is user-toggleable in the UI; this is only the default.
|
||||
|
|
||||
*/
|
||||
|
||||
'diagram' => [
|
||||
'type_labels' => env('TRUSS_TYPE_LABELS', 'native'),
|
||||
|
||||
// Where the browser loads Mermaid from. Null (the default) self-hosts it
|
||||
// from the package's own asset route — no CDN, so a strict CSP needs only
|
||||
// `script-src 'self'`. Set a URL (e.g. a CDN or your own copy) to opt out
|
||||
// of self-hosting: TRUSS_MERMAID_URL=https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js
|
||||
'mermaid_url' => env('TRUSS_MERMAID_URL'),
|
||||
|
||||
// Lower bound for the automatic fit-to-screen: a large schema is never
|
||||
// auto-zoomed below this (it stays legible and you pan). The "Fit" button
|
||||
// ignores this and frames the whole diagram. 1.0 = 100%.
|
||||
'min_zoom' => (float) env('TRUSS_MIN_ZOOM', 0.7),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Theme
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Truss ships a light and dark "blueprint" theme. To match the app Truss is
|
||||
| embedded in, redefine its colours and fonts here. Everything is optional:
|
||||
| only the knobs you set are overridden, the rest stay on the default, so a
|
||||
| handful of values re-skins the whole dashboard (chrome and diagram) in both
|
||||
| light and dark. Config driven, no build step.
|
||||
|
|
||||
| It is delivered as a same-origin stylesheet, so a strict CSP still needs
|
||||
| only style-src 'self' (no inline styles). Each value is validated before it
|
||||
| is emitted; an invalid value is ignored and falls back to the default.
|
||||
|
|
||||
| Colours accept hex, rgb()/rgba()/hsl()/hsla(), or a CSS colour keyword.
|
||||
| Fonts are family names only: name a font your app already loads or a system
|
||||
| font (Truss serves no font files here). Set a knob under both 'light' and
|
||||
| 'dark' to theme both modes; omit 'dark' to theme light only.
|
||||
|
|
||||
| Colour knobs and what each paints:
|
||||
| accent primary accent: headings, PK badges, entity borders, focus ring
|
||||
| accent-secondary secondary accent
|
||||
| background the canvas / page background (and relationship-label backdrop)
|
||||
| surface panels, table bodies, rows, and inputs
|
||||
| surface-alt row striping
|
||||
| text body and diagram text
|
||||
| muted secondary text and the relationship lines / labels
|
||||
| border table, panel, and field lines
|
||||
|
|
||||
*/
|
||||
|
||||
'theme' => [
|
||||
'fonts' => [
|
||||
'mono' => env('TRUSS_THEME_FONT_MONO'),
|
||||
'sans' => env('TRUSS_THEME_FONT_SANS'),
|
||||
],
|
||||
|
||||
'colors' => [
|
||||
'light' => [
|
||||
// 'accent' => '#3730a3',
|
||||
// 'background' => '#ffffff',
|
||||
],
|
||||
'dark' => [
|
||||
// 'accent' => '#a5b4fc',
|
||||
// 'background' => '#0b1020',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Focus
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Focus mode reduces the diagram to a table and its foreign-key neighbours.
|
||||
| `default_depth` is how many hops of neighbours are shown by default.
|
||||
|
|
||||
*/
|
||||
|
||||
'focus' => [
|
||||
'default_depth' => (int) env('TRUSS_FOCUS_DEPTH', 1),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Large schema
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Table count above which the UI shows a "large schema — use focus/filter"
|
||||
| warning before rendering everything at once.
|
||||
|
|
||||
*/
|
||||
|
||||
'large_schema' => [
|
||||
'warn_above' => (int) env('TRUSS_LARGE_SCHEMA_WARN_ABOVE', 60),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Schema diff
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| "What changed since the last migration". After each migration Truss keeps
|
||||
| the previous schema snapshot as a baseline and compares it against the
|
||||
| current one, surfacing added, removed, and changed tables, columns, indexes,
|
||||
| and foreign keys in the dashboard "Changes" panel and via `truss:diff`.
|
||||
|
|
||||
| This is the only feature that writes to the filesystem: the baseline is a
|
||||
| structure-only JSON file (never row data), stored on disk rather than in the
|
||||
| cache because it cannot be rebuilt from the live database once a migration
|
||||
| has run.
|
||||
|
|
||||
| `enabled`: master switch. When false, no baseline is captured, nothing is
|
||||
| written to disk, the "Changes" toggle is hidden, and `truss:diff` reports the
|
||||
| feature is off. Set it false if you do not want Truss touching your disk.
|
||||
|
|
||||
| `disk`: the filesystem disk the baseline is written to, `local` by default.
|
||||
| The path is always `truss/baselines/{connection}`. This deliberately does not
|
||||
| follow the application's default disk: the baseline is derived tooling state,
|
||||
| not application data, so it should not land in a production bucket, cost
|
||||
| money, or have several instances racing on one object. A failure to read or
|
||||
| write it is never fatal; the diff is simply unavailable until it recovers.
|
||||
|
|
||||
*/
|
||||
|
||||
'diff' => [
|
||||
'enabled' => (bool) env('TRUSS_DIFF_ENABLED', true),
|
||||
'disk' => env('TRUSS_DIFF_DISK', 'local'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Doctor
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `truss:doctor` reviews the schema for problems visible from structure
|
||||
| alone (a table with no primary key, an unindexed foreign key, and so on)
|
||||
| and can fail CI. Structure only: it never reads row data and makes no
|
||||
| network call.
|
||||
|
|
||||
| preset: recommended (high-confidence rules), strict (every rule), none.
|
||||
| rules: per-rule overrides keyed by code: false disables, true enables
|
||||
| (even a heuristic one), ['severity' => 'error'] changes severity.
|
||||
| ignore: per-rule fnmatch patterns (table or table.column) to silence.
|
||||
| fail_on: the severity at or above which the command exits non-zero.
|
||||
| exclude: extra tables to skip, on top of truss.excluded_tables.
|
||||
| dashboard: show the findings in the dashboard "Health" panel. When false,
|
||||
| the schema endpoint sends no doctor payload and the panel and
|
||||
| node badges never appear, leaving the CLI/CI doctor untouched.
|
||||
| flag_tables: always mark tables that have findings on the diagram with a
|
||||
| small severity count, even when the Health panel is closed. Set
|
||||
| false to keep the diagram clean and surface findings only when
|
||||
| the panel is open.
|
||||
|
|
||||
*/
|
||||
|
||||
'doctor' => [
|
||||
'preset' => env('TRUSS_DOCTOR_PRESET', 'recommended'),
|
||||
|
||||
'rules' => [
|
||||
// 'TRUSS-INT-002' => true,
|
||||
// 'TRUSS-IDX-001' => ['severity' => 'error'],
|
||||
],
|
||||
|
||||
'ignore' => [
|
||||
// 'TRUSS-IDX-001' => ['audit_log.actor_id'],
|
||||
],
|
||||
|
||||
'fail_on' => env('TRUSS_DOCTOR_FAIL_ON', 'error'),
|
||||
|
||||
'exclude' => [],
|
||||
|
||||
'dashboard' => (bool) env('TRUSS_DOCTOR_DASHBOARD', true),
|
||||
|
||||
'flag_tables' => (bool) env('TRUSS_DOCTOR_FLAG_TABLES', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Annotations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Business meaning that cannot be introspected: that status = 1 means paid,
|
||||
| that total_amount is integer cents, that legacy_orders is deprecated. It is
|
||||
| declared here (and/or read from native schema comments) and rendered into
|
||||
| the exports so a coding agent has grounding a type alone cannot give.
|
||||
|
|
||||
| This is still structure only: native comments are part of the CREATE TABLE
|
||||
| definition, not row content, the same boundary as column defaults.
|
||||
|
|
||||
| source: ordered precedence for resolving an annotation; first match
|
||||
| wins. 'config' reads the maps below; 'database' reads native
|
||||
| table/column comments (MySQL and Postgres; SQLite and SQL Server
|
||||
| have none and are skipped). Drop 'database' to ignore DB comments.
|
||||
| notes: global notes rendered in a header block where the format has one.
|
||||
| tables: per-table annotations, keyed by table name.
|
||||
| columns: per-column annotations, keyed by "table.column".
|
||||
|
|
||||
| --no-annotations (and the facade withoutAnnotations()) strip them all.
|
||||
|
|
||||
*/
|
||||
|
||||
'annotations' => [
|
||||
'source' => ['config', 'database'],
|
||||
|
||||
'notes' => [
|
||||
// 'All timestamps are UTC.',
|
||||
// 'Monetary columns are integer cents unless stated.',
|
||||
],
|
||||
|
||||
'tables' => [
|
||||
// 'orders' => 'One row per order, not per line item.',
|
||||
],
|
||||
|
||||
'columns' => [
|
||||
// 'orders.status' => '0 draft, 1 paid, 2 refunded',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Export
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `default_format`: the format truss:export and the facade use when none is
|
||||
| given. One of dbml, json, csv, markdown, mermaid, or llm.
|
||||
|
|
||||
*/
|
||||
|
||||
'export' => [
|
||||
'default_format' => env('TRUSS_EXPORT_FORMAT', 'dbml'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MCP server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The optional read-only, structure-only MCP server exposes the live schema
|
||||
| to a coding agent (structure only, never row data). It requires the
|
||||
| optional first-party `laravel/mcp` package: install it with
|
||||
| `composer require laravel/mcp`. When that package is absent this switch has
|
||||
| no effect (the server is only registered when the package is present), so a
|
||||
| host that does not opt in is unaffected.
|
||||
|
|
||||
| `enabled`: master switch for registering the server once laravel/mcp is
|
||||
| installed. Defaults on, so installing the package is the only opt-in step.
|
||||
|
|
||||
*/
|
||||
|
||||
'mcp' => [
|
||||
'enabled' => (bool) env('TRUSS_MCP_ENABLED', true),
|
||||
],
|
||||
|
||||
];
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->unsignedInteger('transcription_duration_seconds')
|
||||
->nullable()
|
||||
->after('transcribed_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->dropColumn('transcription_duration_seconds');
|
||||
});
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->json('transcription_verbose')
|
||||
->nullable()
|
||||
->after('transcript');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->dropColumn('transcription_verbose');
|
||||
});
|
||||
}
|
||||
};
|
||||
+22
-13
@@ -14,9 +14,11 @@ x-app-env: &app-env
|
||||
LOG_CHANNEL: stderr
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: /app/database/database.sqlite
|
||||
SESSION_DRIVER: database
|
||||
# File drivers avoid SQLite lock storms: Livewire polls + database queue +
|
||||
# session/cache all writing the same sqlite file caused "database is locked".
|
||||
SESSION_DRIVER: file
|
||||
QUEUE_CONNECTION: database
|
||||
CACHE_STORE: database
|
||||
CACHE_STORE: file
|
||||
BROADCAST_CONNECTION: reverb
|
||||
FILESYSTEM_DISK: local
|
||||
REVERB_APP_ID: ${REVERB_APP_ID:-andytranscribe}
|
||||
@@ -28,6 +30,10 @@ x-app-env: &app-env
|
||||
REVERB_SCHEME: http
|
||||
REVERB_SERVER_HOST: 0.0.0.0
|
||||
REVERB_SERVER_PORT: 8080
|
||||
# Browser-facing Reverb (Blade meta). Overlay sets public host for z00.
|
||||
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-localhost}
|
||||
REVERB_PUBLIC_PORT: ${REVERB_PUBLIC_PORT:-8081}
|
||||
REVERB_PUBLIC_SCHEME: ${REVERB_PUBLIC_SCHEME:-http}
|
||||
LOCAL_WHISPER_URL: http://whisper:8000/v1
|
||||
LOCAL_WHISPER_API_KEY: ${LOCAL_WHISPER_API_KEY:-not-needed}
|
||||
LOCAL_WHISPER_MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
|
||||
@@ -37,8 +43,14 @@ x-app-env: &app-env
|
||||
SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com}
|
||||
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-password}
|
||||
|
||||
# Local: omit APP_IMAGE (builds andytranscribe-app:latest).
|
||||
# CI/prod: set APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> and pull.
|
||||
x-app-image: &app-image
|
||||
image: ${APP_IMAGE:-andytranscribe-app:latest}
|
||||
|
||||
services:
|
||||
app:
|
||||
<<: *app-image
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -48,8 +60,7 @@ services:
|
||||
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
|
||||
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
|
||||
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
|
||||
image: andytranscribe-app:latest
|
||||
container_name: andytranscribe-app
|
||||
container_name: ${CONTAINER_PREFIX:-andytranscribe}-app
|
||||
ports:
|
||||
- "${APP_HOST_PORT:-8080}:80"
|
||||
environment:
|
||||
@@ -70,12 +81,11 @@ services:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
# Shares andytranscribe-app:latest — do not declare build: here (avoids rebuilding 3×).
|
||||
# Shares the app image — do not declare build: here (avoids rebuilding 3×).
|
||||
# `docker compose up --build` builds `app` first, then starts these with the tagged image.
|
||||
queue:
|
||||
image: andytranscribe-app:latest
|
||||
pull_policy: never
|
||||
container_name: andytranscribe-queue
|
||||
<<: *app-image
|
||||
container_name: ${CONTAINER_PREFIX:-andytranscribe}-queue
|
||||
command:
|
||||
- php
|
||||
- artisan
|
||||
@@ -101,9 +111,8 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
reverb:
|
||||
image: andytranscribe-app:latest
|
||||
pull_policy: never
|
||||
container_name: andytranscribe-reverb
|
||||
<<: *app-image
|
||||
container_name: ${CONTAINER_PREFIX:-andytranscribe}-reverb
|
||||
command:
|
||||
- php
|
||||
- artisan
|
||||
@@ -127,7 +136,7 @@ services:
|
||||
|
||||
whisper:
|
||||
image: fedirz/faster-whisper-server:latest-cpu
|
||||
container_name: andytranscribe-whisper
|
||||
container_name: ${CONTAINER_PREFIX:-andytranscribe}-whisper
|
||||
ports:
|
||||
# Host 8090 avoids clashing with the FrankenPHP app on 8080
|
||||
- "${WHISPER_HOST_PORT:-8090}:8000"
|
||||
@@ -148,7 +157,7 @@ services:
|
||||
whisper-gpu:
|
||||
profiles: ["gpu"]
|
||||
image: fedirz/faster-whisper-server:latest-cuda
|
||||
container_name: andytranscribe-whisper-gpu
|
||||
container_name: ${CONTAINER_PREFIX:-andytranscribe}-whisper-gpu
|
||||
ports:
|
||||
- "${WHISPER_HOST_PORT:-8090}:8000"
|
||||
volumes:
|
||||
|
||||
+17
-2
@@ -36,7 +36,22 @@ if [ ! -f vendor/autoload.php ]; then
|
||||
composer install --prefer-dist --no-interaction
|
||||
fi
|
||||
|
||||
php artisan migrate --force --no-interaction
|
||||
php artisan db:seed --force --no-interaction
|
||||
# Only the web app should migrate/seed. Queue and Reverb share the DB and must
|
||||
# not race on sqlite (locks) or re-seed on every restart/deploy.
|
||||
should_bootstrap_db() {
|
||||
case " $* " in
|
||||
*" queue:work "*|*" queue:listen "*|*" reverb:start "*)
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if should_bootstrap_db "$@"; then
|
||||
php artisan migrate --force --no-interaction
|
||||
php artisan db:seed --force --no-interaction
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -32,5 +32,9 @@
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
<env name="REVERB_APP_KEY" value="testing-reverb-key" force="true"/>
|
||||
<env name="REVERB_PUBLIC_HOST" value="reverb.testing.example" force="true"/>
|
||||
<env name="REVERB_PUBLIC_PORT" value="443" force="true"/>
|
||||
<env name="REVERB_PUBLIC_SCHEME" value="https" force="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
+28
-5
@@ -3,13 +3,36 @@ import Echo from 'laravel-echo';
|
||||
import Pusher from 'pusher-js';
|
||||
window.Pusher = Pusher;
|
||||
|
||||
function metaContent(name) {
|
||||
return document.querySelector(`meta[name="${name}"]`)?.getAttribute('content')?.trim() || '';
|
||||
}
|
||||
|
||||
const isLocalHost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
|
||||
const internalHosts = new Set(['', 'reverb', 'localhost', '127.0.0.1']);
|
||||
|
||||
const key = metaContent('reverb-key') || import.meta.env.VITE_REVERB_APP_KEY;
|
||||
|
||||
let host = metaContent('reverb-host') || import.meta.env.VITE_REVERB_HOST || '';
|
||||
let port = metaContent('reverb-port') || import.meta.env.VITE_REVERB_PORT || '';
|
||||
let scheme = metaContent('reverb-scheme') || import.meta.env.VITE_REVERB_SCHEME || 'https';
|
||||
|
||||
if (!isLocalHost && internalHosts.has(host)) {
|
||||
host = `reverb.${window.location.hostname}`;
|
||||
port = '443';
|
||||
scheme = 'https';
|
||||
} else if (isLocalHost && host === 'reverb') {
|
||||
host = import.meta.env.VITE_REVERB_HOST || 'localhost';
|
||||
port = import.meta.env.VITE_REVERB_PORT || '8081';
|
||||
scheme = import.meta.env.VITE_REVERB_SCHEME || 'http';
|
||||
}
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
key,
|
||||
wsHost: host,
|
||||
wsPort: port || 80,
|
||||
wssPort: port || 443,
|
||||
forceTLS: scheme === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authEndpoint: '/broadcasting/auth',
|
||||
auth: {
|
||||
|
||||
+360
-20
@@ -55,31 +55,98 @@ export function formatTimestamp(value) {
|
||||
+ ':' + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
function subscribeToRecording(recordingId, handler) {
|
||||
if (!window.Echo) {
|
||||
function unwrapBroadcast(event) {
|
||||
if (! event || typeof event !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (
|
||||
event.transcript_delta === undefined
|
||||
&& event.transcriptDelta === undefined
|
||||
&& event.whisper_delta === undefined
|
||||
&& event.whisperDelta === undefined
|
||||
&& event.data
|
||||
&& typeof event.data === 'object'
|
||||
) {
|
||||
return event.data;
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
function subscribeToRecording({ recordingId, userId }, handler) {
|
||||
if (! window.Echo) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const channelName = 'recording.' + recordingId;
|
||||
const channel = window.Echo.private(channelName);
|
||||
const channels = [window.Echo.private('recording.' + recordingId)];
|
||||
|
||||
channel.listen('.RecordingTranscriptionUpdated', handler);
|
||||
if (userId) {
|
||||
channels.push(window.Echo.private('user.' + userId + '.recordings'));
|
||||
}
|
||||
|
||||
channels.forEach((channel) => {
|
||||
channel.listen('.RecordingTranscriptionUpdated', handler);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Prefer stopListening over leave() so a remount does not drop other subscribers.
|
||||
if (typeof channel.stopListening === 'function') {
|
||||
channel.stopListening('.RecordingTranscriptionUpdated');
|
||||
}
|
||||
channels.forEach((channel) => {
|
||||
if (typeof channel.stopListening === 'function') {
|
||||
channel.stopListening('.RecordingTranscriptionUpdated');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function whisperSnapshot(snapshot) {
|
||||
return {
|
||||
language: snapshot?.language ?? null,
|
||||
duration: snapshot?.duration ?? null,
|
||||
segments: Array.isArray(snapshot?.segments) ? snapshot.segments.slice() : [],
|
||||
logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs.slice() : [],
|
||||
};
|
||||
}
|
||||
|
||||
function segmentKey(segment) {
|
||||
return [segment?.start ?? '', segment?.end ?? '', segment?.text ?? ''].join('|');
|
||||
}
|
||||
|
||||
export function formatClock(seconds) {
|
||||
if (seconds == null || Number.isNaN(Number(seconds))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = Math.max(0, Number(seconds));
|
||||
const minutes = Math.floor(value / 60);
|
||||
const rest = value - minutes * 60;
|
||||
|
||||
return minutes + ':' + rest.toFixed(1).padStart(4, '0');
|
||||
}
|
||||
|
||||
export function wordConfidenceClass(probability) {
|
||||
if (probability == null) {
|
||||
return 'text-zinc-700 dark:text-zinc-200';
|
||||
}
|
||||
|
||||
if (probability >= 0.85) {
|
||||
return 'text-teal-700 dark:text-teal-300';
|
||||
}
|
||||
|
||||
if (probability >= 0.6) {
|
||||
return 'text-amber-700 dark:text-amber-300';
|
||||
}
|
||||
|
||||
return 'text-red-700 dark:text-red-300';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
|
||||
* Livewire also listens on the user recordings channel and polls while active.
|
||||
* Live updates come from Echo. While a run is active we also hydrate from HTTP
|
||||
* so segment cards keep appearing even if Livewire remorphs or an Echo frame is missed.
|
||||
*/
|
||||
export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
return {
|
||||
statusUrl,
|
||||
userId,
|
||||
status: {
|
||||
...initial,
|
||||
badge_color: badgeColorFor(initial.status),
|
||||
@@ -88,6 +155,9 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
tickTimer: null,
|
||||
hydrateTimer: null,
|
||||
leaveChannel: null,
|
||||
liveFromEcho: false,
|
||||
lastDeltaStamp: null,
|
||||
whisper: whisperSnapshot(initial?.whisper),
|
||||
|
||||
get badgeColor() {
|
||||
return badgeColorFor(this.status.status);
|
||||
@@ -101,19 +171,50 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
|
||||
},
|
||||
|
||||
get whisperWords() {
|
||||
return this.whisper.segments.flatMap((segment, segmentIndex) => {
|
||||
if (Array.isArray(segment.words) && segment.words.length) {
|
||||
return segment.words.map((word, wordIndex) => ({
|
||||
word: word.word,
|
||||
start: word.start,
|
||||
probability: word.probability,
|
||||
key: [segmentIndex, wordIndex, word.start ?? '', word.word ?? ''].join(':'),
|
||||
}));
|
||||
}
|
||||
|
||||
const text = typeof segment.text === 'string' ? segment.text.trim() : '';
|
||||
|
||||
if (text === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
word: text,
|
||||
start: segment.start,
|
||||
probability: null,
|
||||
key: segmentIndex + ':text',
|
||||
}];
|
||||
});
|
||||
},
|
||||
|
||||
start() {
|
||||
this.leaveChannel = subscribeToRecording(this.status.id, (event) => {
|
||||
if (Number(event.id) !== Number(this.status.id)) {
|
||||
this.leaveChannel = subscribeToRecording({
|
||||
recordingId: this.status.id,
|
||||
userId: this.userId,
|
||||
}, (event) => {
|
||||
const payload = unwrapBroadcast(event);
|
||||
|
||||
if (Number(payload.id) !== Number(this.status.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyPayload(event);
|
||||
this.applyPayload(payload, { fromEcho: true });
|
||||
});
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.hydrateOnce();
|
||||
this.beginHydratePoll();
|
||||
this.hydrateOnce();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -127,13 +228,25 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
}
|
||||
},
|
||||
|
||||
applyPayload(payload) {
|
||||
applyPayload(payload, { fromEcho = false } = {}) {
|
||||
const wasActive = this.status.is_active;
|
||||
const transcript = this.mergeTranscript(payload, fromEcho);
|
||||
const nextStatus = payload.status ?? this.status.status;
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
...payload,
|
||||
badge_color: badgeColorFor(payload.status ?? this.status.status),
|
||||
transcript,
|
||||
has_transcript: Boolean(transcript),
|
||||
percent: this.mergePercent(payload, nextStatus),
|
||||
badge_color: badgeColorFor(nextStatus),
|
||||
};
|
||||
|
||||
if (this.shouldResetWhisper(payload, fromEcho)) {
|
||||
this.whisper = whisperSnapshot(null);
|
||||
}
|
||||
|
||||
this.whisper = this.mergeWhisper(payload, fromEcho);
|
||||
this.pollError = null;
|
||||
|
||||
if (this.status.is_active) {
|
||||
@@ -142,6 +255,11 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
} else {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
|
||||
if (wasActive) {
|
||||
this.hydrateOnce();
|
||||
this.refreshLivewire();
|
||||
}
|
||||
}
|
||||
|
||||
if (wasActive && ! this.status.is_active
|
||||
@@ -152,6 +270,209 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
}
|
||||
},
|
||||
|
||||
refreshLivewire() {
|
||||
if (typeof this.$wire?.$refresh === 'function') {
|
||||
this.$wire.$refresh();
|
||||
}
|
||||
},
|
||||
|
||||
mergeTranscript(payload, fromEcho = false) {
|
||||
const delta = payload.transcript_delta || payload.transcriptDelta;
|
||||
const replace = payload.transcript_replace ?? payload.transcriptReplace ?? false;
|
||||
|
||||
if (fromEcho && delta) {
|
||||
const stamp = String(payload.percent ?? '') + ':' + delta;
|
||||
|
||||
if (this.lastDeltaStamp === stamp) {
|
||||
return this.status.transcript;
|
||||
}
|
||||
|
||||
this.lastDeltaStamp = stamp;
|
||||
this.liveFromEcho = true;
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
return delta || '';
|
||||
}
|
||||
|
||||
if (delta) {
|
||||
this.liveFromEcho = this.liveFromEcho || fromEcho;
|
||||
|
||||
return (this.status.transcript || '') + delta;
|
||||
}
|
||||
|
||||
if (this.liveFromEcho && (payload.status ?? this.status.status) === 'processing') {
|
||||
const incoming = typeof payload.transcript === 'string' ? payload.transcript : '';
|
||||
const current = this.status.transcript || '';
|
||||
|
||||
if (incoming.length > current.length) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
if (typeof payload.transcript === 'string') {
|
||||
const current = this.status.transcript || '';
|
||||
|
||||
if (payload.transcript.length < current.length) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return payload.transcript;
|
||||
}
|
||||
|
||||
return this.status.transcript;
|
||||
},
|
||||
|
||||
shouldResetWhisper(payload, fromEcho = false) {
|
||||
const delta = payload.whisper_delta || payload.whisperDelta;
|
||||
const segment = delta?.segment;
|
||||
|
||||
if (
|
||||
fromEcho
|
||||
&& payload.status === 'processing'
|
||||
&& payload.percent === 0
|
||||
&& ! delta
|
||||
&& ! payload.transcript_delta
|
||||
&& ! payload.transcriptDelta
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segment && this.whisper.segments.length) {
|
||||
const last = this.whisper.segments[this.whisper.segments.length - 1];
|
||||
|
||||
if (segment.start != null && last.start != null && Number(segment.start) + 0.05 < Number(last.start)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
mergeWhisper(payload, fromEcho = false) {
|
||||
const snapshot = payload.whisper;
|
||||
const delta = payload.whisper_delta || payload.whisperDelta;
|
||||
|
||||
if (snapshot && Array.isArray(snapshot.segments) && ! delta) {
|
||||
if (fromEcho) {
|
||||
return this.whisper;
|
||||
}
|
||||
|
||||
// HTTP hydrate is source of truth once it is ahead of (or equal to) local Echo state.
|
||||
if (snapshot.segments.length >= this.whisper.segments.length) {
|
||||
return whisperSnapshot(snapshot);
|
||||
}
|
||||
|
||||
const next = whisperSnapshot(this.whisper);
|
||||
|
||||
if (! next.language && snapshot.language) {
|
||||
next.language = snapshot.language;
|
||||
}
|
||||
|
||||
if (next.duration == null && snapshot.duration != null) {
|
||||
next.duration = snapshot.duration;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
if (! delta) {
|
||||
if (snapshot) {
|
||||
const next = whisperSnapshot(this.whisper);
|
||||
|
||||
if (snapshot.language) {
|
||||
next.language = snapshot.language;
|
||||
}
|
||||
|
||||
if (snapshot.duration != null) {
|
||||
next.duration = snapshot.duration;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
return this.whisper;
|
||||
}
|
||||
|
||||
if (fromEcho) {
|
||||
this.liveFromEcho = true;
|
||||
}
|
||||
|
||||
const next = whisperSnapshot(this.whisper);
|
||||
|
||||
if (delta.language) {
|
||||
next.language = delta.language;
|
||||
}
|
||||
|
||||
if (delta.duration != null) {
|
||||
next.duration = delta.duration;
|
||||
}
|
||||
|
||||
if (delta.segment) {
|
||||
next.segments = this.appendSegment(next.segments, delta.segment);
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.segments) && delta.segments.length) {
|
||||
if (! delta.segment) {
|
||||
next.segments = delta.segments.slice();
|
||||
} else {
|
||||
delta.segments.forEach((row) => {
|
||||
next.segments = this.appendSegment(next.segments, row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.logprobs) && delta.logprobs.length) {
|
||||
next.logprobs = next.logprobs.concat(delta.logprobs);
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.words) && delta.words.length && next.segments.length) {
|
||||
const last = { ...next.segments[next.segments.length - 1] };
|
||||
last.words = (last.words || []).concat(delta.words);
|
||||
next.segments = next.segments.slice(0, -1).concat([last]);
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
|
||||
appendSegment(segments, segment) {
|
||||
const key = segmentKey(segment);
|
||||
|
||||
if (segments.some((row) => segmentKey(row) === key)) {
|
||||
return segments;
|
||||
}
|
||||
|
||||
return segments.concat([segment]);
|
||||
},
|
||||
|
||||
seekTo(seconds) {
|
||||
const player = this.$refs.player;
|
||||
|
||||
if (! player || seconds == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.currentTime = Number(seconds);
|
||||
player.play().catch(() => {});
|
||||
},
|
||||
|
||||
mergePercent(payload, status) {
|
||||
if (status !== 'processing') {
|
||||
return payload.percent !== undefined ? payload.percent : this.status.percent;
|
||||
}
|
||||
|
||||
const incoming = payload.percent;
|
||||
const current = Number(this.status.percent) || 0;
|
||||
|
||||
if (incoming == null) {
|
||||
return this.status.percent;
|
||||
}
|
||||
|
||||
return Math.max(current, Number(incoming) || 0);
|
||||
},
|
||||
|
||||
beginTick() {
|
||||
if (this.tickTimer) {
|
||||
return;
|
||||
@@ -172,7 +493,15 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
|
||||
this.hydrateTimer = setInterval(() => {
|
||||
if (! this.status.is_active) {
|
||||
this.stopHydratePoll();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.hydrateOnce();
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
stopHydratePoll() {
|
||||
@@ -188,11 +517,20 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
}
|
||||
|
||||
const next = this.status.elapsed_seconds + 1;
|
||||
const duration = Number(this.status.duration_seconds) || 0;
|
||||
let percent = this.status.percent;
|
||||
|
||||
if (this.status.status === 'processing' && duration > 0) {
|
||||
const elapsedPercent = Math.min(99, Math.round((100 * next) / duration));
|
||||
|
||||
percent = Math.max(Number(percent) || 0, elapsedPercent);
|
||||
}
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
elapsed_seconds: next,
|
||||
elapsed_human: formatElapsed(next),
|
||||
percent,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -220,12 +558,14 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
formatElapsed,
|
||||
formatDuration,
|
||||
formatTimestamp,
|
||||
formatClock,
|
||||
wordConfidenceClass,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Index-page Alpine component: inline audio player only.
|
||||
* Status/progress refresh via Livewire Echo + wire:poll.
|
||||
* Status/progress refresh via wire:poll. Live transcript on the show page uses Echo.
|
||||
*/
|
||||
export function recordingsIndex() {
|
||||
return {
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
@php
|
||||
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
|
||||
$usedPercent = min(100, max(0, (float) $disk['used_percent']));
|
||||
$usedPercentLabel = rtrim(rtrim(number_format($usedPercent, 1, '.', ''), '0'), '.') ?: '0';
|
||||
$freePercent = (float) ($disk['free_percent'] ?? 100);
|
||||
// Inline colors so the fill is visible even before / without a Tailwind rebuild.
|
||||
$fillColor = match (true) {
|
||||
$freePercent <= 5 => '#dc2626',
|
||||
$freePercent <= 15 => '#f59e0b',
|
||||
default => '#0d9488',
|
||||
};
|
||||
@endphp
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
|
||||
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $disk['used_percent'] }}% used"
|
||||
class="flex min-w-36 flex-col gap-1 rounded-lg border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
|
||||
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $usedPercentLabel }}% used"
|
||||
>
|
||||
<div
|
||||
class="h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700"
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="{{ (int) round($disk['used_percent']) }}"
|
||||
aria-label="Disk space used"
|
||||
aria-valuenow="{{ (int) round($usedPercent) }}"
|
||||
aria-label="Disk space {{ $usedPercentLabel }}% used"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}"
|
||||
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
|
||||
class="h-full rounded-full transition-[width] duration-300"
|
||||
style="width: {{ $usedPercent }}%; background-color: {{ $fillColor }};"
|
||||
></div>
|
||||
</div>
|
||||
<span class="hidden text-xs font-medium tabular-nums text-zinc-600 sm:inline dark:text-zinc-300">
|
||||
{{ $disk['free_human'] }} free
|
||||
<span class="text-xs font-medium tabular-nums text-zinc-600 dark:text-zinc-300">
|
||||
{{ $usedPercentLabel }}% used · {{ $disk['free_human'] }} free
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
Words
|
||||
</flux:table.column>
|
||||
<flux:table.column
|
||||
class="w-36"
|
||||
class="w-44"
|
||||
sortable
|
||||
:sorted="$sortBy === 'status'"
|
||||
:direction="$sortDirection"
|
||||
@@ -181,11 +181,18 @@
|
||||
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="w-36 whitespace-nowrap">
|
||||
<x-transcription-status-badge
|
||||
:status="$recording->transcription_status"
|
||||
:label="$recording->transcriptionStatusLabel()"
|
||||
/>
|
||||
<flux:table.cell class="w-44 whitespace-nowrap">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<x-transcription-status-badge
|
||||
:status="$recording->transcription_status"
|
||||
:label="$recording->transcriptionStatusLabel()"
|
||||
/>
|
||||
@if ($recording->transcription_status === 'processing' && $recording->transcription_percent > 0)
|
||||
<span class="tabular-nums text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{{ $recording->transcription_percent }}%
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
{{--
|
||||
Full wire:ignore: Livewire must not remorph this tree while Echo/Alpine own the live UI.
|
||||
Status + whisper segments hydrate over HTTP while active (no wire:poll here).
|
||||
--}}
|
||||
<div
|
||||
@if ($recording->isTranscribing())
|
||||
wire:poll.2s.visible
|
||||
@endif
|
||||
wire:ignore
|
||||
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcribed_at?->timestamp }}"
|
||||
x-data="transcriptionMonitor(@js([
|
||||
'statusUrl' => route('recordings.transcription-status', $recording),
|
||||
'userId' => (int) $recording->user_id,
|
||||
'initial' => $recording->transcriptionStatusPayload(),
|
||||
]))"
|
||||
x-init="
|
||||
start();
|
||||
return () => destroy();
|
||||
"
|
||||
>
|
||||
<div
|
||||
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}-{{ md5((string) $recording->transcription_progress) }}-{{ $recording->transcribed_at?->timestamp }}"
|
||||
x-data="transcriptionMonitor(@js([
|
||||
'statusUrl' => route('recordings.transcription-status', $recording),
|
||||
'initial' => $recording->transcriptionStatusPayload(),
|
||||
]))"
|
||||
x-init="
|
||||
start();
|
||||
return () => destroy();
|
||||
"
|
||||
>
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm">← Recordings</flux:link>
|
||||
@@ -164,7 +165,7 @@
|
||||
<flux:callout.text>
|
||||
<span
|
||||
class="tabular-nums"
|
||||
x-show="status.status === 'processing' && status.percent != null"
|
||||
x-show="status.status === 'processing' && Number(status.percent) > 0"
|
||||
x-cloak
|
||||
>
|
||||
<span x-text="status.percent + '%'"></span>
|
||||
@@ -174,15 +175,29 @@
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
|
||||
<div class="mt-3" x-show="status.status === 'processing'" x-cloak>
|
||||
<div class="mt-3" x-show="status.status === 'processing' && Number(status.percent) > 0" x-cloak>
|
||||
<flux:progress color="amber" x-bind:value="status.percent || 0" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
|
||||
x-show="status.transcript && !whisperWords.length"
|
||||
x-text="status.transcript"
|
||||
></div>
|
||||
|
||||
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<li>
|
||||
Engine:
|
||||
<span class="font-medium" x-text="status.driver_label || '—'"></span>
|
||||
</li>
|
||||
<li x-show="whisper.language">
|
||||
Detected language:
|
||||
<span class="font-medium uppercase" x-text="whisper.language"></span>
|
||||
</li>
|
||||
<li x-show="whisper.duration">
|
||||
Whisper duration:
|
||||
<span class="font-medium" x-text="formatClock(whisper.duration)"></span>
|
||||
</li>
|
||||
<template x-if="status.duration_seconds">
|
||||
<li>
|
||||
Audio length:
|
||||
@@ -212,14 +227,56 @@
|
||||
</div>
|
||||
|
||||
<div x-show="!status.is_active && status.has_transcript" x-cloak>
|
||||
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="status.transcript"></p>
|
||||
<p
|
||||
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
|
||||
x-show="!whisperWords.length"
|
||||
x-text="status.transcript"
|
||||
></p>
|
||||
<flux:text
|
||||
class="mt-4 text-xs"
|
||||
x-show="status.transcribed_at"
|
||||
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
|
||||
x-text="status.transcribed_at
|
||||
? ('Transcribed ' + formatTimestamp(status.transcribed_at)
|
||||
+ (status.transcription_duration_human ? (' · took ' + status.transcription_duration_human) : ''))
|
||||
: ''"
|
||||
></flux:text>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 space-y-3"
|
||||
x-show="whisper.language || whisperWords.length"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 text-xs text-zinc-600 dark:text-zinc-400"
|
||||
x-show="!status.is_active && (whisper.language || whisper.duration)"
|
||||
>
|
||||
<span x-show="whisper.language">
|
||||
Language
|
||||
<span class="font-medium uppercase text-zinc-800 dark:text-zinc-100" x-text="whisper.language"></span>
|
||||
</span>
|
||||
<span x-show="whisper.duration">
|
||||
· Duration
|
||||
<span class="font-medium text-zinc-800 dark:text-zinc-100" x-text="formatClock(whisper.duration)"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 text-sm leading-relaxed"
|
||||
x-show="whisperWords.length"
|
||||
>
|
||||
<template x-for="word in whisperWords" :key="word.key">
|
||||
<button
|
||||
type="button"
|
||||
class="mr-1 inline cursor-pointer hover:underline"
|
||||
:class="wordConfidenceClass(word.probability)"
|
||||
:title="(word.start != null ? formatClock(word.start) : '') + (word.probability != null ? (' · ' + Math.round(word.probability * 100) + '%') : '')"
|
||||
@click="seekTo(word.start)"
|
||||
x-text="word.word"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:text
|
||||
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
|
||||
x-cloak
|
||||
@@ -228,5 +285,4 @@
|
||||
No transcript yet. Transcription starts automatically after upload, or use the button above.
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta name="reverb-key" content="{{ config('reverb.client.key') }}">
|
||||
<meta name="reverb-host" content="{{ config('reverb.client.host') }}">
|
||||
<meta name="reverb-port" content="{{ config('reverb.client.port') }}">
|
||||
<meta name="reverb-scheme" content="{{ config('reverb.client.scheme') }}">
|
||||
|
||||
<title>
|
||||
{{ filled($title ?? null) ? $title.' — '.config('app.name', 'AndyTranscribe') : config('app.name', 'AndyTranscribe') }}
|
||||
@@ -11,4 +15,36 @@
|
||||
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@fluxAppearance
|
||||
{{-- Flux appearance with dark as the default (Flux ships with "system"). --}}
|
||||
<style>
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.Flux = {
|
||||
applyAppearance (appearance) {
|
||||
let applyDark = () => document.documentElement.classList.add('dark')
|
||||
let applyLight = () => document.documentElement.classList.remove('dark')
|
||||
|
||||
if (appearance === 'system') {
|
||||
let media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
// Persist "system" explicitly so a missing key can mean "use app default (dark)".
|
||||
window.localStorage.setItem('flux.appearance', 'system')
|
||||
|
||||
media.matches ? applyDark() : applyLight()
|
||||
} else if (appearance === 'dark') {
|
||||
window.localStorage.setItem('flux.appearance', 'dark')
|
||||
|
||||
applyDark()
|
||||
} else if (appearance === 'light') {
|
||||
window.localStorage.setItem('flux.appearance', 'light')
|
||||
|
||||
applyLight()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.Flux.applyAppearance(window.localStorage.getItem('flux.appearance') || 'dark')
|
||||
</script>
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Deploy a pre-built APP_IMAGE to one or more AndyTranscribe directories.
|
||||
|
||||
Environment:
|
||||
APP_IMAGE Required. e.g. gitea.z00.nu/ben/andytranscribe:abc1234
|
||||
DEPLOY_PATHS Comma-separated instance directories (default: current directory)
|
||||
DEPLOY_SHA Optional git SHA to hard-reset each directory to (CI sets this)
|
||||
GIT_PULL When 1 (default) and DEPLOY_SHA is empty, run git pull --ff-only
|
||||
|
||||
Usage:
|
||||
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:tag DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh
|
||||
EOF
|
||||
}
|
||||
|
||||
IMAGE="${APP_IMAGE:-${1:-}}"
|
||||
if [[ -z "${IMAGE}" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PATHS_CSV="${DEPLOY_PATHS:-${2:-.}}"
|
||||
GIT_PULL="${GIT_PULL:-1}"
|
||||
DEPLOY_SHA="${DEPLOY_SHA:-}"
|
||||
IFS=',' read -r -a DIRS <<< "${PATHS_CSV}"
|
||||
|
||||
for dir in "${DIRS[@]}"; do
|
||||
dir="${dir#"${dir%%[![:space:]]*}"}"
|
||||
dir="${dir%"${dir##*[![:space:]]}"}"
|
||||
|
||||
if [[ ! -d "${dir}" ]]; then
|
||||
echo "Missing instance directory: ${dir}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
name="$(basename "${dir}")"
|
||||
echo "==> Deploying ${IMAGE} in ${dir}"
|
||||
|
||||
(
|
||||
cd "${dir}"
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
if [[ -n "${DEPLOY_SHA}" ]]; then
|
||||
git fetch --force origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
if ! git cat-file -e "${DEPLOY_SHA}^{commit}" 2>/dev/null; then
|
||||
git fetch --force origin "${DEPLOY_SHA}"
|
||||
fi
|
||||
git checkout --force --detach "${DEPLOY_SHA}"
|
||||
git reset --hard "${DEPLOY_SHA}"
|
||||
# Drop local edits/hot-patches; keep runtime data and secrets.
|
||||
git clean -fd \
|
||||
--exclude=database/ \
|
||||
--exclude=storage/ \
|
||||
--exclude=.env \
|
||||
--exclude=.env.*
|
||||
elif [[ "${GIT_PULL}" == "1" ]]; then
|
||||
git pull --ff-only
|
||||
fi
|
||||
fi
|
||||
|
||||
export APP_IMAGE="${IMAGE}"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-${name,,}}"
|
||||
|
||||
compose_files=(-f docker-compose.yml)
|
||||
if [[ -f compose.z00.yaml ]]; then
|
||||
compose_files+=(-f compose.z00.yaml)
|
||||
fi
|
||||
|
||||
if grep -q '^APP_IMAGE=' .env 2>/dev/null; then
|
||||
sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${IMAGE}|" .env
|
||||
else
|
||||
printf '\nAPP_IMAGE=%s\n' "${IMAGE}" >> .env
|
||||
fi
|
||||
|
||||
docker compose "${compose_files[@]}" pull app queue reverb
|
||||
docker compose "${compose_files[@]}" up -d --remove-orphans
|
||||
docker compose "${compose_files[@]}" ps
|
||||
|
||||
app_port="$(awk -F= '/^APP_HOST_PORT=/ {print $2; exit}' .env 2>/dev/null || true)"
|
||||
app_port="${app_port:-18080}"
|
||||
health_url="http://127.0.0.1:${app_port}/up"
|
||||
echo "waiting for ${health_url}"
|
||||
ok=0
|
||||
for _ in $(seq 1 45); do
|
||||
if curl -fsS "${health_url}" >/dev/null 2>&1; then
|
||||
echo "healthy"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "${ok}" -ne 1 ]]; then
|
||||
echo "ERROR: health check failed for ${name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Hit /login as the public HTTPS edge would (trustProxies needs forwarded proto).
|
||||
app_url="$(awk -F= '/^APP_URL=/ {print $2; exit}' .env 2>/dev/null || true)"
|
||||
app_url="${app_url:-https://transcribe.z00.nu}"
|
||||
public_host="$(python3 - <<PY
|
||||
from urllib.parse import urlparse
|
||||
print(urlparse("${app_url}").hostname or "transcribe.z00.nu")
|
||||
PY
|
||||
)"
|
||||
asset_scheme="$(
|
||||
curl -fsS \
|
||||
-H "X-Forwarded-Proto: https" \
|
||||
-H "X-Forwarded-Host: ${public_host}" \
|
||||
-H "X-Forwarded-Port: 443" \
|
||||
"http://127.0.0.1:${app_port}/login" \
|
||||
| grep -oE 'https?://[^"'\'' ]+\.css' \
|
||||
| head -1 || true
|
||||
)"
|
||||
if [[ "${asset_scheme}" == http://* ]]; then
|
||||
echo "ERROR: login page still emits http:// asset URLs (${asset_scheme})" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "asset check ok: ${asset_scheme:-no absolute css url (relative/ok)}"
|
||||
)
|
||||
done
|
||||
|
||||
echo "Deploy complete: ${IMAGE}"
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Idempotent bootstrap for AndyTranscribe Gitea Actions secrets on z00.
|
||||
# Requires an already-running Gitea + act_runner (see airports setup).
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_DIR="${GITEA_DIR:-${HOME}/gitea}"
|
||||
REPO_OWNER="${REPO_OWNER:-ben}"
|
||||
REPO_NAME="${REPO_NAME:-AndyTranscribe}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST:-gitea.z00.nu}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-${HOME}/andyTranscibe}"
|
||||
STAGE_DEPLOY_PATH="${STAGE_DEPLOY_PATH:-${HOME}/andyTranscibe-stage}"
|
||||
CREDENTIALS_FILE="${GITEA_DIR}/.credentials"
|
||||
|
||||
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
|
||||
echo "Missing ${CREDENTIALS_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "${CREDENTIALS_FILE}"
|
||||
|
||||
API="https://${REGISTRY_HOST}/api/v1"
|
||||
AUTH=(-u "${ADMIN_USERNAME}:${ADMIN_PASSWORD}")
|
||||
|
||||
if ! curl -fsS "${AUTH[@]}" "${API}/repos/${REPO_OWNER}/${REPO_NAME}" >/dev/null 2>&1; then
|
||||
echo "Repository ${REPO_OWNER}/${REPO_NAME} not found on ${REGISTRY_HOST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CI_TOKEN="$(
|
||||
docker exec -u git gitea gitea admin user generate-access-token \
|
||||
-u "${ADMIN_USERNAME}" \
|
||||
-t "ci-${REPO_NAME}-$(date +%Y%m%d%H%M%S)" \
|
||||
--scopes "write:package,read:package,write:repository,read:repository" \
|
||||
--raw
|
||||
)"
|
||||
|
||||
PULL_TOKEN="$(
|
||||
docker exec -u git gitea gitea admin user generate-access-token \
|
||||
-u "${ADMIN_USERNAME}" \
|
||||
-t "pull-${REPO_NAME}-$(date +%Y%m%d%H%M%S)" \
|
||||
--scopes "read:package" \
|
||||
--raw
|
||||
)"
|
||||
|
||||
set_secret() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
python3 -c 'import json,sys; json.dump({"data": sys.argv[1]}, open(sys.argv[2], "w"))' "${value}" "${tmp}"
|
||||
curl -fsS "${AUTH[@]}" -X PUT \
|
||||
"${API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/secrets/${name}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @"${tmp}" >/dev/null
|
||||
rm -f "${tmp}"
|
||||
}
|
||||
|
||||
set_secret "REGISTRY_TOKEN" "${CI_TOKEN}"
|
||||
set_secret "DEPLOY_PATHS" "${DEPLOY_PATH}"
|
||||
set_secret "STAGE_DEPLOY_PATHS" "${STAGE_DEPLOY_PATH}"
|
||||
|
||||
printf '%s' "${PULL_TOKEN}" | docker login "${REGISTRY_HOST}" -u "${ADMIN_USERNAME}" --password-stdin
|
||||
|
||||
REPO_LC="$(echo "${REPO_OWNER}/${REPO_NAME}" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ -f "${DEPLOY_PATH}/.env" ]]; then
|
||||
if grep -q '^APP_IMAGE=' "${DEPLOY_PATH}/.env"; then
|
||||
sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${REGISTRY_HOST}/${REPO_LC}:latest|" "${DEPLOY_PATH}/.env"
|
||||
else
|
||||
printf '\nAPP_IMAGE=%s/%s:latest\n' "${REGISTRY_HOST}" "${REPO_LC}" >> "${DEPLOY_PATH}/.env"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure the existing host runner is up (shared with airports).
|
||||
if systemctl --user is-enabled gitea-act-runner.service >/dev/null 2>&1; then
|
||||
systemctl --user restart gitea-act-runner.service || true
|
||||
systemctl --user --no-pager --lines=5 status gitea-act-runner.service || true
|
||||
fi
|
||||
|
||||
echo "Gitea CI secrets configured for ${REGISTRY_HOST}/${REPO_OWNER}/${REPO_NAME}"
|
||||
echo "Deploy path: ${DEPLOY_PATH}"
|
||||
echo "Stage deploy path: ${STAGE_DEPLOY_PATH}"
|
||||
echo "Push to main to deploy production; push to stage to deploy https://stage.transcribe.z00.nu"
|
||||
echo "First-time stage instance: ./scripts/setup-stage.sh"
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time bootstrap for the AndyTranscribe staging instance on z00.
|
||||
# Creates ~/andyTranscibe-stage, a unique .env, the stage Caddy Docker network,
|
||||
# and the Gitea STAGE_DEPLOY_PATHS secret.
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_DIR="${GITEA_DIR:-${HOME}/gitea}"
|
||||
REPO_OWNER="${REPO_OWNER:-ben}"
|
||||
REPO_NAME="${REPO_NAME:-AndyTranscribe}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST:-gitea.z00.nu}"
|
||||
STAGE_PATH="${STAGE_PATH:-${HOME}/andyTranscibe-stage}"
|
||||
GITEA_REPO_URL="https://${REGISTRY_HOST}/${REPO_OWNER}/${REPO_NAME}.git"
|
||||
STAGE_URL="${STAGE_URL:-https://stage.transcribe.z00.nu}"
|
||||
REVERB_PUBLIC_HOST="${REVERB_PUBLIC_HOST:-reverb.stage.transcribe.z00.nu}"
|
||||
CONTAINER_PREFIX="${CONTAINER_PREFIX:-andytranscribe-stage}"
|
||||
CADDY_NETWORK="${CADDY_NETWORK:-andytranscribe-stage-caddy}"
|
||||
PROD_CADDY_NETWORK="${PROD_CADDY_NETWORK:-caddy-proxy-manager-test_caddy-test-network}"
|
||||
CADDY_CONTAINER="${CADDY_CONTAINER:-}"
|
||||
CREDENTIALS_FILE="${GITEA_DIR}/.credentials"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
upsert_env() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
python3 - "$file" "$key" "$value" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
text = open(path).read()
|
||||
pattern = re.compile(r"^" + re.escape(key) + r"=.*$", re.M)
|
||||
replacement = f"{key}={value}"
|
||||
if pattern.search(text):
|
||||
text = pattern.sub(replacement, text, count=1)
|
||||
else:
|
||||
text = text.rstrip("\n") + "\n" + replacement + "\n"
|
||||
open(path, "w").write(text)
|
||||
PY
|
||||
}
|
||||
|
||||
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
|
||||
echo "Missing ${CREDENTIALS_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "${CREDENTIALS_FILE}"
|
||||
|
||||
API="https://${REGISTRY_HOST}/api/v1"
|
||||
AUTH=(-u "${ADMIN_USERNAME}:${ADMIN_PASSWORD}")
|
||||
|
||||
if ! curl -fsS "${AUTH[@]}" "${API}/repos/${REPO_OWNER}/${REPO_NAME}" >/dev/null 2>&1; then
|
||||
echo "Repository ${REPO_OWNER}/${REPO_NAME} not found on ${REGISTRY_HOST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${STAGE_PATH}/.git" ]]; then
|
||||
mkdir -p "$(dirname "${STAGE_PATH}")"
|
||||
git clone "${GITEA_REPO_URL}" "${STAGE_PATH}"
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${STAGE_PATH}"
|
||||
# Never fetch from the prod checkout; CI deploys by SHA against this origin.
|
||||
git remote set-url origin "${GITEA_REPO_URL}"
|
||||
git fetch origin || true
|
||||
if git rev-parse --verify origin/stage >/dev/null 2>&1; then
|
||||
git checkout stage
|
||||
git pull --ff-only origin stage || true
|
||||
else
|
||||
echo "Remote branch origin/stage not found yet; leaving $(git branch --show-current)."
|
||||
fi
|
||||
)
|
||||
|
||||
mkdir -p \
|
||||
"${STAGE_PATH}/database" \
|
||||
"${STAGE_PATH}/storage/app/private/recordings" \
|
||||
"${STAGE_PATH}/storage/app/public" \
|
||||
"${STAGE_PATH}/storage/framework/cache" \
|
||||
"${STAGE_PATH}/storage/framework/sessions" \
|
||||
"${STAGE_PATH}/storage/framework/views" \
|
||||
"${STAGE_PATH}/storage/logs" \
|
||||
"${STAGE_PATH}/bootstrap/cache"
|
||||
|
||||
if [[ ! -f "${STAGE_PATH}/database/database.sqlite" ]]; then
|
||||
touch "${STAGE_PATH}/database/database.sqlite"
|
||||
fi
|
||||
|
||||
ENV_SRC="${STAGE_PATH}/.env.example"
|
||||
if [[ ! -f "${ENV_SRC}" ]]; then
|
||||
ENV_SRC="${REPO_ROOT}/.env.example"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${STAGE_PATH}/.env" ]]; then
|
||||
cp "${ENV_SRC}" "${STAGE_PATH}/.env"
|
||||
upsert_env "${STAGE_PATH}/.env" "APP_ENV" "production"
|
||||
upsert_env "${STAGE_PATH}/.env" "APP_DEBUG" "false"
|
||||
upsert_env "${STAGE_PATH}/.env" "APP_KEY" "base64:$(openssl rand -base64 32)"
|
||||
upsert_env "${STAGE_PATH}/.env" "APP_URL" "${STAGE_URL}"
|
||||
upsert_env "${STAGE_PATH}/.env" "PUBLIC_APP_URL" "${STAGE_URL}"
|
||||
# Prod already binds 18080 (app), 18180 (reverb), and 18090 (whisper).
|
||||
upsert_env "${STAGE_PATH}/.env" "APP_HOST_PORT" "18280"
|
||||
upsert_env "${STAGE_PATH}/.env" "REVERB_HOST_PORT" "18281"
|
||||
upsert_env "${STAGE_PATH}/.env" "WHISPER_HOST_PORT" "18290"
|
||||
upsert_env "${STAGE_PATH}/.env" "REVERB_APP_ID" "$(openssl rand -hex 8)"
|
||||
upsert_env "${STAGE_PATH}/.env" "REVERB_APP_KEY" "$(openssl rand -hex 16)"
|
||||
upsert_env "${STAGE_PATH}/.env" "REVERB_APP_SECRET" "$(openssl rand -hex 20)"
|
||||
upsert_env "${STAGE_PATH}/.env" "REVERB_PUBLIC_HOST" "${REVERB_PUBLIC_HOST}"
|
||||
upsert_env "${STAGE_PATH}/.env" "REVERB_PUBLIC_PORT" "443"
|
||||
upsert_env "${STAGE_PATH}/.env" "REVERB_PUBLIC_SCHEME" "https"
|
||||
upsert_env "${STAGE_PATH}/.env" "CONTAINER_PREFIX" "${CONTAINER_PREFIX}"
|
||||
upsert_env "${STAGE_PATH}/.env" "CADDY_NETWORK" "${CADDY_NETWORK}"
|
||||
echo "Wrote ${STAGE_PATH}/.env"
|
||||
else
|
||||
echo "Keeping existing ${STAGE_PATH}/.env"
|
||||
fi
|
||||
|
||||
if ! docker network inspect "${CADDY_NETWORK}" >/dev/null 2>&1; then
|
||||
docker network create "${CADDY_NETWORK}"
|
||||
echo "Created Docker network ${CADDY_NETWORK}"
|
||||
fi
|
||||
|
||||
if [[ -z "${CADDY_CONTAINER}" ]]; then
|
||||
CADDY_CONTAINER="$(
|
||||
docker network inspect "${PROD_CADDY_NETWORK}" \
|
||||
--format '{{range .Containers}}{{.Name}}{{"\n"}}{{end}}' \
|
||||
| grep -i caddy \
|
||||
| head -1 || true
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ -z "${CADDY_CONTAINER}" ]]; then
|
||||
echo "Could not find a Caddy container on ${PROD_CADDY_NETWORK}." >&2
|
||||
echo "Set CADDY_CONTAINER and re-run: docker network connect ${CADDY_NETWORK} <caddy-container>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if docker network inspect "${CADDY_NETWORK}" --format '{{range .Containers}}{{.Name}}{{"\n"}}{{end}}' | grep -qx "${CADDY_CONTAINER}"; then
|
||||
echo "Caddy container ${CADDY_CONTAINER} already on ${CADDY_NETWORK}"
|
||||
else
|
||||
docker network connect "${CADDY_NETWORK}" "${CADDY_CONTAINER}"
|
||||
echo "Attached ${CADDY_CONTAINER} to ${CADDY_NETWORK}"
|
||||
fi
|
||||
|
||||
set_secret() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
python3 -c 'import json,sys; json.dump({"data": sys.argv[1]}, open(sys.argv[2], "w"))' "${value}" "${tmp}"
|
||||
curl -fsS "${AUTH[@]}" -X PUT \
|
||||
"${API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/secrets/${name}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @"${tmp}" >/dev/null
|
||||
rm -f "${tmp}"
|
||||
}
|
||||
|
||||
set_secret "STAGE_DEPLOY_PATHS" "${STAGE_PATH}"
|
||||
|
||||
echo "Stage instance ready at ${STAGE_PATH}"
|
||||
echo "Public URL: ${STAGE_URL}"
|
||||
echo "Reverb host: ${REVERB_PUBLIC_HOST}"
|
||||
echo "Caddy network: ${CADDY_NETWORK} (container ${CADDY_CONTAINER})"
|
||||
echo "Gitea secret STAGE_DEPLOY_PATHS=${STAGE_PATH}"
|
||||
echo
|
||||
echo "Still required: DNS for stage.transcribe.z00.nu and ${REVERB_PUBLIC_HOST},"
|
||||
echo "plus Caddy Proxy Manager hosts pointing at ${CONTAINER_PREFIX}-app:80"
|
||||
echo "and ${CONTAINER_PREFIX}-reverb:8080."
|
||||
echo "Then: git checkout -b stage && git push -u origin stage"
|
||||
@@ -50,10 +50,17 @@ class DiskSpaceTest extends TestCase
|
||||
{
|
||||
Cache::flush();
|
||||
|
||||
$this->get(route('recordings.index'))
|
||||
$response = $this->get(route('recordings.index'))
|
||||
->assertOk()
|
||||
->assertSee('Disk space used', false)
|
||||
->assertSee('Disk space', false)
|
||||
->assertSee('% used', false)
|
||||
->assertSee('free')
|
||||
->assertSee('role="progressbar"', false);
|
||||
->assertSee('role="progressbar"', false)
|
||||
->assertSee('background-color:', false);
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/width:\s*[\d.]+%;\s*background-color:\s*#(0d9488|f59e0b|dc2626)/',
|
||||
$response->getContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ use App\Services\TranscriptionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Ai\Transcription;
|
||||
use Livewire\Livewire;
|
||||
@@ -202,6 +204,333 @@ class RecordingUploadTest extends TestCase
|
||||
->assertJsonPath('driver_label', 'Local (faster-whisper)');
|
||||
}
|
||||
|
||||
public function test_transcription_status_endpoint_includes_whisper_snapshot(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Verbose status',
|
||||
'original_filename' => 'live.mp3',
|
||||
'file_path' => 'recordings/live.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 40,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
'transcription_verbose' => [
|
||||
'language' => 'en',
|
||||
'duration' => 8.5,
|
||||
'segments' => [
|
||||
['text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->getJson(route('recordings.transcription-status', $recording))
|
||||
->assertOk()
|
||||
->assertJsonPath('whisper.language', 'en')
|
||||
->assertJsonPath('whisper.duration', 8.5)
|
||||
->assertJsonPath('whisper.segments.0.text', 'Hello');
|
||||
}
|
||||
|
||||
public function test_transcription_service_calls_local_whisper_http(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response(['text' => 'Hello from whisper.']),
|
||||
]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Long whisper',
|
||||
'original_filename' => 'long.mp3',
|
||||
'file_path' => 'recordings/long.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
$text = app(TranscriptionService::class)->transcribe($recording);
|
||||
|
||||
$this->assertSame('Hello from whisper.', $text);
|
||||
|
||||
Http::assertSent(function ($request): bool {
|
||||
$body = $request->body();
|
||||
|
||||
return str_contains($request->url(), '/audio/transcriptions')
|
||||
&& str_contains($body, 'name="stream"')
|
||||
&& str_contains($body, 'true')
|
||||
&& str_contains($body, 'verbose_json')
|
||||
&& str_contains($body, 'timestamp_granularities[]')
|
||||
&& str_contains($body, 'word');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_transcription_service_streams_sse_progress_and_text(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||
|
||||
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \",\"end\":10}\n\n"
|
||||
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\",\"end\":30}\n\n"
|
||||
."data: {\"type\":\"transcript.text.done\",\"text\":\"Hello from whisper.\",\"end\":40}\n\n";
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||
]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Streamed whisper',
|
||||
'original_filename' => 'long.mp3',
|
||||
'file_path' => 'recordings/long.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'duration_seconds' => 40,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
$partials = [];
|
||||
|
||||
$text = app(TranscriptionService::class)->transcribe(
|
||||
$recording,
|
||||
function (string $message, int $percent, ?string $partial = null) use (&$partials): void {
|
||||
$partials[] = [
|
||||
'percent' => $percent,
|
||||
'partial' => $partial,
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
$this->assertSame('Hello from whisper.', $text);
|
||||
$this->assertNotEmpty(array_filter($partials, fn (array $row): bool => $row['partial'] === 'Hello '));
|
||||
$this->assertSame('Hello from whisper.', $partials[array_key_last($partials)]['partial'] ?? $text);
|
||||
$this->assertContains(0, array_column($partials, 'percent'));
|
||||
$this->assertContains(25, array_column($partials, 'percent'));
|
||||
$this->assertContains(99, array_column($partials, 'percent'));
|
||||
$this->assertLessThan(100, max(array_column($partials, 'percent')));
|
||||
}
|
||||
|
||||
public function test_transcription_service_streams_legacy_segments_with_timestamp_percent(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||
|
||||
$sse = "data: {\"text\":\"Hello from\",\"start\":0,\"end\":20}\n\n"
|
||||
."data: {\"text\":\"whisper.\",\"start\":20,\"end\":40}\n\n";
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||
]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Legacy stream',
|
||||
'original_filename' => 'long.mp3',
|
||||
'file_path' => 'recordings/long.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'duration_seconds' => 40,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
$percents = [];
|
||||
$partials = [];
|
||||
|
||||
$text = app(TranscriptionService::class)->transcribe(
|
||||
$recording,
|
||||
function (string $message, int $percent, ?string $partial = null) use (&$percents, &$partials): void {
|
||||
$percents[] = $percent;
|
||||
if ($partial !== null) {
|
||||
$partials[] = $partial;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$this->assertSame('Hello from whisper.', $text);
|
||||
$this->assertContains('Hello from', $partials);
|
||||
$this->assertContains(50, $percents);
|
||||
$this->assertContains(99, $percents);
|
||||
}
|
||||
|
||||
public function test_transcription_service_streams_verbose_segment_metadata(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||
|
||||
$segment = [
|
||||
'id' => 0,
|
||||
'start' => 0.0,
|
||||
'end' => 20.0,
|
||||
'text' => 'Hello from whisper.',
|
||||
'tokens' => [50364, 2425],
|
||||
'avg_logprob' => -0.18,
|
||||
'compression_ratio' => 1.2,
|
||||
'no_speech_prob' => 0.02,
|
||||
'language' => 'en',
|
||||
'duration' => 40.0,
|
||||
'words' => [
|
||||
['word' => 'Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.96],
|
||||
],
|
||||
];
|
||||
|
||||
$sse = 'data: '.json_encode($segment)."\n\n";
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||
]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Verbose stream',
|
||||
'original_filename' => 'long.mp3',
|
||||
'file_path' => 'recordings/long.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'duration_seconds' => 40,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
$whispers = [];
|
||||
|
||||
$text = app(TranscriptionService::class)->transcribe(
|
||||
$recording,
|
||||
function (string $message, int $percent, ?string $partial = null, ?array $whisper = null) use (&$whispers): void {
|
||||
if ($whisper !== null) {
|
||||
$whispers[] = $whisper;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$this->assertSame('Hello from whisper.', $text);
|
||||
$this->assertNotEmpty($whispers);
|
||||
$this->assertSame('en', $whispers[0]['language']);
|
||||
$this->assertSame(-0.18, $whispers[0]['segment']['avg_logprob']);
|
||||
$this->assertSame('Hello', $whispers[0]['segment']['words'][0]['word']);
|
||||
}
|
||||
|
||||
public function test_transcription_service_does_not_invent_percent_when_events_lack_timestamps(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||
|
||||
$this->freezeTime();
|
||||
|
||||
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \"}\n\n"
|
||||
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\"}\n\n";
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||
]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'No timestamp percent',
|
||||
'original_filename' => 'long.mp3',
|
||||
'file_path' => 'recordings/long.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'duration_seconds' => 40,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now()->subSeconds(20),
|
||||
]);
|
||||
|
||||
$partials = [];
|
||||
|
||||
$text = app(TranscriptionService::class)->transcribe(
|
||||
$recording,
|
||||
function (string $message, int $percent, ?string $partial = null) use (&$partials): void {
|
||||
$partials[] = [
|
||||
'percent' => $percent,
|
||||
'partial' => $partial,
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
$this->assertSame('Hello from whisper.', $text);
|
||||
$this->assertContains('Hello ', array_column($partials, 'partial'));
|
||||
$this->assertContains('Hello from whisper.', array_column($partials, 'partial'));
|
||||
$this->assertSame([0], array_values(array_unique(array_column($partials, 'percent'))));
|
||||
}
|
||||
|
||||
public function test_transcription_service_uses_word_timestamps_for_percent(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||
|
||||
$sse = 'data: '.json_encode([
|
||||
'type' => 'transcript.text.delta',
|
||||
'delta' => 'Hello ',
|
||||
'words' => [
|
||||
['word' => 'Hello', 'start' => 0.0, 'end' => 10.0],
|
||||
],
|
||||
])."\n\n"
|
||||
.'data: '.json_encode([
|
||||
'type' => 'transcript.text.delta',
|
||||
'delta' => 'from whisper.',
|
||||
'words' => [
|
||||
['word' => 'from', 'start' => 10.0, 'end' => 20.0],
|
||||
['word' => 'whisper.', 'start' => 20.0, 'end' => 30.0],
|
||||
],
|
||||
])."\n\n";
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||
]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Word timestamps',
|
||||
'original_filename' => 'long.mp3',
|
||||
'file_path' => 'recordings/long.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'duration_seconds' => 40,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
$percents = [];
|
||||
|
||||
$text = app(TranscriptionService::class)->transcribe(
|
||||
$recording,
|
||||
function (string $message, int $percent, ?string $partial = null) use (&$percents): void {
|
||||
$percents[] = $percent;
|
||||
},
|
||||
);
|
||||
|
||||
$this->assertSame('Hello from whisper.', $text);
|
||||
$this->assertContains(25, $percents);
|
||||
$this->assertContains(75, $percents);
|
||||
$this->assertLessThan(100, max($percents));
|
||||
}
|
||||
|
||||
public function test_report_progress_persists_partial_transcript_without_completing(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Partial',
|
||||
'original_filename' => 'partial.mp3',
|
||||
'file_path' => 'recordings/partial.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
'transcript' => 'Previous transcript',
|
||||
]);
|
||||
|
||||
$recording->reportProgress('Transcribing locally…', 62, partialTranscript: 'Hello from the ');
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('processing', $recording->transcription_status);
|
||||
$this->assertSame(62, $recording->transcription_percent);
|
||||
$this->assertSame('Hello from the ', $recording->transcript);
|
||||
$this->assertNull($recording->transcribed_at);
|
||||
$this->assertTrue($recording->transcriptionStatusPayload()['has_transcript']);
|
||||
$this->assertSame('Hello from the ', $recording->transcriptionStatusPayload()['transcript']);
|
||||
}
|
||||
|
||||
public function test_transcription_job_stores_transcript(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
@@ -209,6 +538,8 @@ class RecordingUploadTest extends TestCase
|
||||
|
||||
Transcription::fake(['Hello from the recorder.']);
|
||||
|
||||
$this->travelTo(now()->startOfSecond());
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Sample',
|
||||
@@ -217,6 +548,7 @@ class RecordingUploadTest extends TestCase
|
||||
'file_size_bytes' => 12,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now()->subSeconds(42),
|
||||
]);
|
||||
|
||||
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
|
||||
@@ -227,6 +559,12 @@ class RecordingUploadTest extends TestCase
|
||||
$this->assertSame(100, $recording->transcription_percent);
|
||||
$this->assertSame('Transcription complete', $recording->transcription_progress);
|
||||
$this->assertNotNull($recording->transcribed_at);
|
||||
$this->assertSame(
|
||||
$recording->transcribed_at->getTimestamp() - $recording->transcription_started_at->getTimestamp(),
|
||||
$recording->transcription_duration_seconds,
|
||||
);
|
||||
$this->assertSame(42, $recording->transcription_duration_seconds);
|
||||
$this->assertSame('42s', $recording->transcriptionStatusPayload()['transcription_duration_human']);
|
||||
}
|
||||
|
||||
public function test_transcription_job_stores_error_on_failure(): void
|
||||
@@ -283,7 +621,70 @@ class RecordingUploadTest extends TestCase
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('failed', $recording->transcription_status);
|
||||
$this->assertStringContainsString('worker stopped', $recording->transcription_error);
|
||||
$this->assertStringContainsString('timed out', $recording->transcription_error);
|
||||
}
|
||||
|
||||
public function test_stale_reserved_job_is_treated_as_orphaned(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Wedged whisper',
|
||||
'original_filename' => 'wedged.mp3',
|
||||
'file_path' => 'recordings/wedged.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 50,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
$job = new TranscribeRecording($recording);
|
||||
$payload = json_encode([
|
||||
'displayName' => TranscribeRecording::class,
|
||||
'data' => [
|
||||
'command' => serialize($job),
|
||||
],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
DB::table('jobs')->insert([
|
||||
'queue' => 'default',
|
||||
'payload' => $payload,
|
||||
'attempts' => 1,
|
||||
'reserved_at' => now()->subMinutes(15)->timestamp,
|
||||
'available_at' => now()->subMinutes(20)->timestamp,
|
||||
'created_at' => now()->subMinutes(20)->timestamp,
|
||||
]);
|
||||
|
||||
$this->assertFalse($recording->hasActiveTranscriptionJob());
|
||||
$this->assertTrue($recording->isOrphanedTranscription());
|
||||
|
||||
$deleted = $recording->discardQueuedTranscriptionJobs();
|
||||
$this->assertSame(1, $deleted, 'stale reserved job should be discarded');
|
||||
$this->assertDatabaseCount('jobs', 0);
|
||||
|
||||
// Put the stale job back to exercise status-poll recovery.
|
||||
DB::table('jobs')->insert([
|
||||
'queue' => 'default',
|
||||
'payload' => $payload,
|
||||
'attempts' => 1,
|
||||
'reserved_at' => now()->subMinutes(15)->timestamp,
|
||||
'available_at' => now()->subMinutes(20)->timestamp,
|
||||
'created_at' => now()->subMinutes(20)->timestamp,
|
||||
]);
|
||||
$recording->forceFill([
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_error' => null,
|
||||
])->save();
|
||||
|
||||
$this->getJson(route('recordings.transcription-status', $recording))
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'failed');
|
||||
|
||||
$this->assertDatabaseCount('jobs', 0);
|
||||
$recording->refresh();
|
||||
$this->assertSame('failed', $recording->transcription_status);
|
||||
}
|
||||
|
||||
public function test_recent_processing_is_not_marked_orphaned(): void
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Jobs\TranscribeRecording;
|
||||
use App\Livewire\Recordings\Index;
|
||||
use App\Models\Recording;
|
||||
use App\Models\User;
|
||||
use App\Services\DiskSpaceService;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
@@ -17,6 +18,15 @@ class IndexTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->mock(DiskSpaceService::class, function ($mock): void {
|
||||
$mock->shouldReceive('snapshot')->andReturn(null);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_index_page_renders_as_livewire(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
@@ -125,11 +135,37 @@ class IndexTest extends TestCase
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('wire:poll', false)
|
||||
->assertDontSee('echo-private', false)
|
||||
->assertSee('Transcribing')
|
||||
->assertSee('40%')
|
||||
->assertDontSee('Transcribing locally…')
|
||||
->assertSeeHtml('bg-amber-400');
|
||||
}
|
||||
|
||||
public function test_processing_recordings_do_not_show_zero_percent(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Recording::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Just started',
|
||||
'original_filename' => 'fresh.mp3',
|
||||
'file_path' => 'recordings/fresh.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 0,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('Just started')
|
||||
->assertSee('Transcribing')
|
||||
->assertDontSeeHtml('tabular-nums text-xs text-zinc-500');
|
||||
}
|
||||
|
||||
public function test_queued_recordings_do_not_show_fake_percent(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -60,7 +60,7 @@ class ShowTest extends TestCase
|
||||
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
|
||||
}
|
||||
|
||||
public function test_show_polls_while_transcription_is_active(): void
|
||||
public function test_show_uses_alpine_hydrate_while_transcription_is_queued(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
@@ -71,7 +71,7 @@ class ShowTest extends TestCase
|
||||
'original_filename' => 'active.mp3',
|
||||
'file_path' => 'recordings/active.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_progress' => 'Queued — waiting to start…',
|
||||
'transcription_percent' => null,
|
||||
'transcription_driver' => 'local',
|
||||
@@ -79,10 +79,98 @@ class ShowTest extends TestCase
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertSee('wire:poll', false)
|
||||
->assertDontSee('wire:poll', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertSee('transcriptionMonitor', false)
|
||||
->assertSee('Queued — waiting to start…');
|
||||
}
|
||||
|
||||
public function test_show_does_not_use_livewire_poll_while_transcription_is_processing(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Live stream',
|
||||
'original_filename' => 'active.mp3',
|
||||
'file_path' => 'recordings/active.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 20,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertDontSee('wire:poll', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertSee('userId', false);
|
||||
}
|
||||
|
||||
public function test_show_page_includes_partial_transcript_while_processing(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Live words',
|
||||
'original_filename' => 'active.mp3',
|
||||
'file_path' => 'recordings/active.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 62,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
'transcript' => 'Live partial sentence from whisper',
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertSee('Live partial sentence from whisper')
|
||||
->assertSee('status.transcript', false);
|
||||
}
|
||||
|
||||
public function test_show_page_includes_live_whisper_segment_bindings(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Verbose live',
|
||||
'original_filename' => 'active.mp3',
|
||||
'file_path' => 'recordings/active.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
'transcript' => 'Hello',
|
||||
'transcription_verbose' => [
|
||||
'language' => 'en',
|
||||
'duration' => 4.0,
|
||||
'segments' => [
|
||||
[
|
||||
'text' => 'Hello',
|
||||
'start' => 0.0,
|
||||
'end' => 1.0,
|
||||
'avg_logprob' => -0.1,
|
||||
'words' => [
|
||||
['word' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'probability' => 0.9],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertSee('whisperWords', false)
|
||||
->assertSee('whisper.language', false)
|
||||
->assertSee('Hello');
|
||||
}
|
||||
|
||||
public function test_show_does_not_poll_when_transcription_is_idle(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
@@ -102,7 +190,7 @@ class ShowTest extends TestCase
|
||||
->assertDontSee('wire:poll', false);
|
||||
}
|
||||
|
||||
public function test_show_refreshes_recording_on_transcription_broadcast(): void
|
||||
public function test_show_poll_refresh_picks_up_processing_status(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
@@ -128,12 +216,68 @@ class ShowTest extends TestCase
|
||||
]);
|
||||
|
||||
$component
|
||||
->call('onTranscriptionUpdated', [
|
||||
'id' => $recording->id,
|
||||
'status' => 'processing',
|
||||
])
|
||||
->call('$refresh')
|
||||
->assertSet('recording.transcription_status', 'processing')
|
||||
->assertSet('recording.transcription_percent', 40)
|
||||
->assertSee('Transcribing locally…');
|
||||
}
|
||||
|
||||
public function test_show_poll_refresh_picks_up_completed_transcript(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Almost done',
|
||||
'original_filename' => 'live.mp3',
|
||||
'file_path' => 'recordings/live.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 90,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
'transcript' => 'Partial',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Show::class, ['recording' => $recording]);
|
||||
|
||||
$recording->update([
|
||||
'transcription_status' => 'done',
|
||||
'transcription_percent' => 100,
|
||||
'transcript' => 'Finished live transcript',
|
||||
'transcribed_at' => now(),
|
||||
]);
|
||||
|
||||
$component
|
||||
->call('$refresh')
|
||||
->assertSet('recording.transcription_status', 'done')
|
||||
->assertSet('recording.transcription_percent', 100)
|
||||
->assertSee('Finished live transcript');
|
||||
}
|
||||
|
||||
public function test_show_alpine_root_key_does_not_include_percent(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Stable key',
|
||||
'original_filename' => 'live.mp3',
|
||||
'file_path' => 'recordings/live.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 62,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertSee('transcription-ui-'.$recording->id.'-processing-', false)
|
||||
->assertDontSee('transcription-ui-'.$recording->id.'-processing-62', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class ReverbClientConfigTest extends TestCase
|
||||
{
|
||||
public function test_login_page_exposes_environment_reverb_client_settings(): void
|
||||
{
|
||||
$this->get(route('login'))
|
||||
->assertOk()
|
||||
->assertSee('<meta name="reverb-key" content="testing-reverb-key">', false)
|
||||
->assertSee('<meta name="reverb-host" content="reverb.testing.example">', false)
|
||||
->assertSee('<meta name="reverb-port" content="443">', false)
|
||||
->assertSee('<meta name="reverb-scheme" content="https">', false);
|
||||
}
|
||||
|
||||
public function test_login_page_reflects_runtime_reverb_client_config(): void
|
||||
{
|
||||
config([
|
||||
'reverb.client.key' => 'stage-reverb-key',
|
||||
'reverb.client.host' => 'reverb.stage.transcribe.z00.nu',
|
||||
'reverb.client.port' => '443',
|
||||
'reverb.client.scheme' => 'https',
|
||||
]);
|
||||
|
||||
$this->get(route('login'))
|
||||
->assertOk()
|
||||
->assertSee('<meta name="reverb-key" content="stage-reverb-key">', false)
|
||||
->assertSee('<meta name="reverb-host" content="reverb.stage.transcribe.z00.nu">', false);
|
||||
}
|
||||
}
|
||||
@@ -160,4 +160,221 @@ class TranscriptionBroadcastTest extends TestCase
|
||||
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
|
||||
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
|
||||
}
|
||||
|
||||
public function test_broadcast_payload_omits_transcript_text(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Long text',
|
||||
'original_filename' => 'long.mp3',
|
||||
'file_path' => 'recordings/long.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_percent' => 70,
|
||||
'transcript' => str_repeat('word ', 5000),
|
||||
]);
|
||||
|
||||
$payload = (new RecordingTranscriptionUpdated($recording))->broadcastWith();
|
||||
|
||||
$this->assertArrayNotHasKey('transcript', $payload);
|
||||
$this->assertArrayNotHasKey('whisper', $payload);
|
||||
$this->assertTrue($payload['has_transcript']);
|
||||
$this->assertSame(70, $payload['percent']);
|
||||
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
|
||||
}
|
||||
|
||||
public function test_report_progress_broadcasts_transcript_deltas_for_append(): void
|
||||
{
|
||||
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Deltas',
|
||||
'original_filename' => 'delta.mp3',
|
||||
'file_path' => 'recordings/delta.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
'transcript' => 'Previous finished transcript',
|
||||
]);
|
||||
|
||||
$recording->reportProgress('Transcribing locally…', 55, partialTranscript: 'Hello ');
|
||||
|
||||
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
||||
$payload = $event->broadcastWith();
|
||||
|
||||
return ($payload['transcript_delta'] ?? null) === 'Hello '
|
||||
&& ($payload['transcript_replace'] ?? false) === true
|
||||
&& ! array_key_exists('transcript', $payload);
|
||||
});
|
||||
|
||||
$recording->refresh()->reportProgress('Transcribing locally…', 60, partialTranscript: 'Hello from whisper.');
|
||||
|
||||
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
||||
$payload = $event->broadcastWith();
|
||||
|
||||
return ($payload['transcript_delta'] ?? null) === 'from whisper.'
|
||||
&& ($payload['transcript_replace'] ?? true) === false;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_report_progress_broadcasts_whisper_delta_without_full_verbose_snapshot(): void
|
||||
{
|
||||
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Verbose live',
|
||||
'original_filename' => 'delta.mp3',
|
||||
'file_path' => 'recordings/delta.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
]);
|
||||
|
||||
$recording->reportProgress('Transcribing locally…', 40, partialTranscript: 'Hello', whisperDelta: [
|
||||
'language' => 'en',
|
||||
'duration' => 12.5,
|
||||
'segment' => [
|
||||
'id' => 0,
|
||||
'start' => 0.0,
|
||||
'end' => 1.2,
|
||||
'text' => 'Hello',
|
||||
'avg_logprob' => -0.2,
|
||||
'words' => [
|
||||
['word' => 'Hello', 'start' => 0.0, 'end' => 1.2, 'probability' => 0.91],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('en', $recording->transcription_verbose['language']);
|
||||
$this->assertCount(1, $recording->transcription_verbose['segments']);
|
||||
|
||||
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
||||
$payload = $event->broadcastWith();
|
||||
|
||||
return ($payload['whisper_delta']['language'] ?? null) === 'en'
|
||||
&& ($payload['whisper_delta']['segment']['text'] ?? null) === 'Hello'
|
||||
&& ! array_key_exists('whisper', $payload)
|
||||
&& ! array_key_exists('transcript', $payload)
|
||||
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_full_whisper_segments_snapshot_is_persisted_but_not_broadcast(): void
|
||||
{
|
||||
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Snapshot',
|
||||
'original_filename' => 'snap.mp3',
|
||||
'file_path' => 'recordings/snap.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
]);
|
||||
|
||||
$recording->reportProgress('Transcribing locally…', 90, partialTranscript: 'Hello world', whisperDelta: [
|
||||
'language' => 'en',
|
||||
'duration' => 4.2,
|
||||
'segments' => [
|
||||
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
|
||||
['id' => 1, 'text' => 'world', 'start' => 1.0, 'end' => 2.0],
|
||||
],
|
||||
]);
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertCount(2, $recording->transcription_verbose['segments']);
|
||||
|
||||
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
||||
$payload = $event->broadcastWith();
|
||||
|
||||
return ($payload['whisper_delta']['language'] ?? null) === 'en'
|
||||
&& ($payload['whisper_delta']['duration'] ?? null) === 4.2
|
||||
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? [])
|
||||
&& ! array_key_exists('whisper', $payload);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_report_progress_replaces_verbose_segments_when_snapshot_arrives(): void
|
||||
{
|
||||
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Replace segments',
|
||||
'original_filename' => 'replace.mp3',
|
||||
'file_path' => 'recordings/replace.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
]);
|
||||
|
||||
$recording->reportProgress('Transcribing locally…', 40, whisperDelta: [
|
||||
'segment' => ['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
|
||||
]);
|
||||
$recording->refresh()->reportProgress('Transcribing locally…', 99, whisperDelta: [
|
||||
'language' => 'en',
|
||||
'segments' => [
|
||||
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'avg_logprob' => -0.1],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $recording->refresh()->transcription_verbose['segments']);
|
||||
$this->assertSame(-0.1, $recording->transcription_verbose['segments'][0]['avg_logprob']);
|
||||
}
|
||||
|
||||
public function test_consecutive_live_whisper_segments_are_appended(): void
|
||||
{
|
||||
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Append live',
|
||||
'original_filename' => 'append.mp3',
|
||||
'file_path' => 'recordings/append.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
]);
|
||||
|
||||
$recording->reportProgress('Transcribing locally…', 40, partialTranscript: "didn't I wasn't aware", whisperDelta: [
|
||||
'language' => 'en',
|
||||
'segment' => ['id' => 0, 'text' => "didn't I wasn't aware", 'start' => 254.5, 'end' => 260.8],
|
||||
]);
|
||||
$recording->refresh()->reportProgress('Transcribing locally…', 45, partialTranscript: "didn't I wasn't aware other people", whisperDelta: [
|
||||
'language' => 'en',
|
||||
'segment' => ['id' => 0, 'text' => 'other people', 'start' => 260.8, 'end' => 267.4],
|
||||
]);
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertCount(2, $recording->transcription_verbose['segments']);
|
||||
$this->assertSame('other people', $recording->transcription_verbose['segments'][1]['text']);
|
||||
}
|
||||
|
||||
public function test_oversized_transcript_delta_is_not_broadcast(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'title' => 'Huge delta',
|
||||
'original_filename' => 'huge.mp3',
|
||||
'file_path' => 'recordings/huge.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
]);
|
||||
|
||||
$huge = str_repeat('a', RecordingTranscriptionUpdated::MAX_DELTA_BYTES + 1);
|
||||
$payload = (new RecordingTranscriptionUpdated($recording, $huge, true))->broadcastWith();
|
||||
|
||||
$this->assertArrayNotHasKey('transcript_delta', $payload);
|
||||
$this->assertArrayNotHasKey('transcript', $payload);
|
||||
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -6,5 +6,11 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Feature tests render Blade without a Vite build in CI.
|
||||
$this->withoutVite();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SetupStageScriptTest extends TestCase
|
||||
{
|
||||
public function test_clones_from_gitea_and_retargets_origin(): void
|
||||
{
|
||||
$script = file_get_contents(dirname(__DIR__, 2).'/scripts/setup-stage.sh');
|
||||
|
||||
$this->assertIsString($script);
|
||||
$this->assertStringContainsString('git clone "${GITEA_REPO_URL}" "${STAGE_PATH}"', $script);
|
||||
$this->assertStringContainsString('git remote set-url origin "${GITEA_REPO_URL}"', $script);
|
||||
$this->assertStringNotContainsString('git clone "${PROD_PATH}"', $script);
|
||||
$this->assertStringContainsString('APP_HOST_PORT" "18280"', $script);
|
||||
$this->assertStringNotContainsString('APP_HOST_PORT" "18180"', $script);
|
||||
}
|
||||
|
||||
public function test_deploy_fetches_advertised_refs_before_sha(): void
|
||||
{
|
||||
$script = file_get_contents(dirname(__DIR__, 2).'/scripts/deploy-production.sh');
|
||||
|
||||
$this->assertIsString($script);
|
||||
$this->assertStringContainsString("git fetch --force origin '+refs/heads/*:refs/remotes/origin/*'", $script);
|
||||
$this->assertStringContainsString('git checkout --force --detach "${DEPLOY_SHA}"', $script);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\WhisperTranscriptionStream;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class WhisperTranscriptionStreamTest extends TestCase
|
||||
{
|
||||
private WhisperTranscriptionStream $stream;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->stream = new WhisperTranscriptionStream;
|
||||
}
|
||||
|
||||
public function test_extracts_sse_data_payloads_from_buffer(): void
|
||||
{
|
||||
$buffer = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello\"}\n\npartial";
|
||||
|
||||
$payloads = $this->stream->extractPayloads($buffer);
|
||||
|
||||
$this->assertSame(['{"type":"transcript.text.delta","delta":"Hello"}'], $payloads);
|
||||
$this->assertSame('partial', $buffer);
|
||||
}
|
||||
|
||||
public function test_parses_openai_delta_and_done_events(): void
|
||||
{
|
||||
$delta = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"Hello "}');
|
||||
$done = $this->stream->parseEvent('{"type":"transcript.text.done","text":"Hello world"}');
|
||||
|
||||
$this->assertSame('Hello ', $delta['append']);
|
||||
$this->assertFalse($delta['done']);
|
||||
$this->assertSame('Hello world', $done['replace']);
|
||||
$this->assertTrue($done['done']);
|
||||
}
|
||||
|
||||
public function test_parses_legacy_segment_events(): void
|
||||
{
|
||||
$event = $this->stream->parseEvent('{"text":"First segment","start":0,"end":12.5}');
|
||||
|
||||
$this->assertTrue($event['legacy']);
|
||||
$this->assertSame('First segment', $event['append']);
|
||||
$this->assertSame(12.5, $event['end']);
|
||||
$this->assertSame('First segment', $event['whisper']['segment']['text']);
|
||||
$this->assertSame(12.5, $event['whisper']['segment']['end']);
|
||||
}
|
||||
|
||||
public function test_parses_typed_segment_events_as_legacy_chunks(): void
|
||||
{
|
||||
$event = $this->stream->parseEvent(json_encode([
|
||||
'type' => 'segment',
|
||||
'start' => 0.0,
|
||||
'end' => 2.4,
|
||||
'text' => 'Hello, how are you?',
|
||||
'avg_logprob' => -0.12,
|
||||
'words' => [
|
||||
['word' => 'Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.99],
|
||||
],
|
||||
]));
|
||||
|
||||
$this->assertTrue($event['legacy']);
|
||||
$this->assertSame('Hello, how are you?', $event['append']);
|
||||
$this->assertSame(2.4, $event['end']);
|
||||
$this->assertSame(-0.12, $event['whisper']['segment']['avg_logprob']);
|
||||
$this->assertSame('Hello', $event['whisper']['segment']['words'][0]['word']);
|
||||
}
|
||||
|
||||
public function test_parses_verbose_segment_words_language_and_logprobs(): void
|
||||
{
|
||||
$json = json_encode([
|
||||
'language' => 'en',
|
||||
'duration' => 8.5,
|
||||
'text' => 'Hello world',
|
||||
'id' => 0,
|
||||
'start' => 0.0,
|
||||
'end' => 1.6,
|
||||
'tokens' => [50364, 2425, 1002],
|
||||
'avg_logprob' => -0.21,
|
||||
'compression_ratio' => 1.15,
|
||||
'no_speech_prob' => 0.01,
|
||||
'temperature' => 0.0,
|
||||
'words' => [
|
||||
['word' => 'Hello', 'start' => 0.0, 'end' => 0.6, 'probability' => 0.94],
|
||||
['word' => 'world', 'start' => 0.7, 'end' => 1.5, 'probability' => 0.88],
|
||||
],
|
||||
]);
|
||||
|
||||
$event = $this->stream->parseEvent($json);
|
||||
|
||||
$this->assertSame('en', $event['whisper']['language']);
|
||||
$this->assertSame(8.5, $event['whisper']['duration']);
|
||||
$this->assertSame([50364, 2425, 1002], $event['whisper']['segment']['tokens']);
|
||||
$this->assertSame(-0.21, $event['whisper']['segment']['avg_logprob']);
|
||||
$this->assertCount(2, $event['whisper']['segment']['words']);
|
||||
$this->assertSame('Hello', $event['whisper']['segment']['words'][0]['word']);
|
||||
}
|
||||
|
||||
public function test_parses_openai_delta_logprobs_without_treating_them_as_a_segment(): void
|
||||
{
|
||||
$event = $this->stream->parseEvent(json_encode([
|
||||
'type' => 'transcript.text.delta',
|
||||
'delta' => 'Hel',
|
||||
'logprobs' => [
|
||||
['token' => 'Hel', 'logprob' => -0.05],
|
||||
],
|
||||
]));
|
||||
|
||||
$this->assertSame('Hel', $event['append']);
|
||||
$this->assertArrayNotHasKey('segment', $event['whisper']);
|
||||
$this->assertSame('Hel', $event['whisper']['logprobs'][0]['token']);
|
||||
$this->assertSame(-0.05, $event['whisper']['logprobs'][0]['logprob']);
|
||||
}
|
||||
|
||||
public function test_applies_delta_then_done_replace(): void
|
||||
{
|
||||
$delta = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"Hel"}');
|
||||
$text = $this->stream->applyEvent('', $delta);
|
||||
$delta2 = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"lo"}');
|
||||
$text = $this->stream->applyEvent($text, $delta2);
|
||||
$done = $this->stream->parseEvent('{"type":"transcript.text.done","text":"Hello world"}');
|
||||
$text = $this->stream->applyEvent($text, $done);
|
||||
|
||||
$this->assertSame('Hello world', $text);
|
||||
}
|
||||
|
||||
public function test_parses_verbose_json_segments_array(): void
|
||||
{
|
||||
$event = $this->stream->parseEvent(json_encode([
|
||||
'task' => 'transcribe',
|
||||
'language' => 'en',
|
||||
'duration' => 4.2,
|
||||
'text' => 'Hello world',
|
||||
'segments' => [
|
||||
[
|
||||
'id' => 0,
|
||||
'start' => 0.0,
|
||||
'end' => 4.2,
|
||||
'text' => ' Hello world',
|
||||
'avg_logprob' => -0.3,
|
||||
'tokens' => [1, 2],
|
||||
'words' => [
|
||||
['word' => ' Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.9],
|
||||
],
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$this->assertSame('Hello world', $event['replace']);
|
||||
$this->assertSame(4.2, $event['end']);
|
||||
$this->assertSame('en', $event['whisper']['language']);
|
||||
$this->assertCount(1, $event['whisper']['segments']);
|
||||
$this->assertSame(-0.3, $event['whisper']['segments'][0]['avg_logprob']);
|
||||
}
|
||||
|
||||
public function test_live_segment_with_nested_segments_array_appends_instead_of_replacing(): void
|
||||
{
|
||||
$first = $this->stream->parseEvent(json_encode([
|
||||
'language' => 'en',
|
||||
'duration' => 6.3,
|
||||
'id' => 0,
|
||||
'start' => 254.5,
|
||||
'end' => 260.8,
|
||||
'text' => "didn't I wasn't aware",
|
||||
'segments' => [
|
||||
['id' => 0, 'start' => 254.5, 'end' => 260.8, 'text' => "didn't I wasn't aware"],
|
||||
],
|
||||
]));
|
||||
$second = $this->stream->parseEvent(json_encode([
|
||||
'language' => 'en',
|
||||
'duration' => 6.96,
|
||||
'id' => 0,
|
||||
'start' => 260.8,
|
||||
'end' => 267.4,
|
||||
'text' => ' other people so I am trying',
|
||||
'segments' => [
|
||||
['id' => 0, 'start' => 260.8, 'end' => 267.4, 'text' => ' other people so I am trying'],
|
||||
],
|
||||
]));
|
||||
|
||||
$this->assertTrue($first['legacy']);
|
||||
$this->assertNull($first['replace']);
|
||||
$this->assertArrayNotHasKey('segments', $first['whisper']);
|
||||
$this->assertSame("didn't I wasn't aware", $first['whisper']['segment']['text']);
|
||||
|
||||
$text = $this->stream->applyEvent('', $first);
|
||||
$text = $this->stream->applyEvent($text, $second);
|
||||
|
||||
$this->assertSame("didn't I wasn't aware other people so I am trying", $text);
|
||||
}
|
||||
|
||||
public function test_joins_legacy_segments_with_spaces(): void
|
||||
{
|
||||
$first = $this->stream->parseEvent('{"text":"Hello from","end":10}');
|
||||
$second = $this->stream->parseEvent('{"text":"whisper.","end":20}');
|
||||
|
||||
$text = $this->stream->applyEvent('', $first);
|
||||
$text = $this->stream->applyEvent($text, $second);
|
||||
|
||||
$this->assertSame('Hello from whisper.', $text);
|
||||
}
|
||||
|
||||
public function test_ignores_done_sentinel_and_non_json(): void
|
||||
{
|
||||
$buffer = "data: [DONE]\n\ndata: not-json\n\n";
|
||||
|
||||
$payloads = $this->stream->extractPayloads($buffer);
|
||||
|
||||
$this->assertSame(['not-json'], $payloads);
|
||||
$this->assertNull($this->stream->parseEvent('not-json'));
|
||||
}
|
||||
|
||||
public function test_flush_buffer_emits_trailing_frame(): void
|
||||
{
|
||||
$buffer = 'data: {"text":"Last"}';
|
||||
|
||||
$payloads = $this->stream->flushBuffer($buffer);
|
||||
|
||||
$this->assertSame(['{"text":"Last"}'], $payloads);
|
||||
$this->assertSame('', $buffer);
|
||||
}
|
||||
|
||||
public function test_openai_delta_uses_word_end_as_audio_position(): void
|
||||
{
|
||||
$event = $this->stream->parseEvent(json_encode([
|
||||
'type' => 'transcript.text.delta',
|
||||
'delta' => 'Hello',
|
||||
'words' => [
|
||||
['word' => 'Hello', 'start' => 0.0, 'end' => 1.6, 'probability' => 0.9],
|
||||
],
|
||||
]));
|
||||
|
||||
$this->assertSame(1.6, $event['end']);
|
||||
$this->assertSame('Hello', $event['whisper']['words'][0]['word']);
|
||||
}
|
||||
|
||||
public function test_file_duration_is_not_used_as_audio_position(): void
|
||||
{
|
||||
$event = $this->stream->parseEvent(json_encode([
|
||||
'type' => 'transcript.text.delta',
|
||||
'delta' => 'Hello',
|
||||
'duration' => 40.0,
|
||||
]));
|
||||
|
||||
$this->assertNull($event['end']);
|
||||
$this->assertSame(40.0, $event['whisper']['duration']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user