Artem Kastrov hace 1 mes
padre
commit
e347325147
Se han modificado 65 ficheros con 3109 adiciones y 198 borrados
  1. 14 0
      app/Enums/Status.php
  2. 42 0
      app/Events/Streaming.php
  3. 41 0
      app/Events/Wisper.php
  4. 100 0
      app/Helpers/ChunkParser.php
  5. 52 0
      app/Http/Controllers/Auth/AuthenticatedSessionController.php
  6. 25 34
      app/Http/Controllers/ChatController.php
  7. 20 0
      app/Http/Controllers/MessageController.php
  8. 13 1
      app/Http/Middleware/HandleInertiaRequests.php
  9. 88 0
      app/Http/Requests/Auth/LoginRequest.php
  10. 9 1
      app/Http/Resources/ChatResource.php
  11. 92 0
      app/Jobs/AskQuestionJob.php
  12. 47 0
      app/Jobs/SetChatTitleJob.php
  13. 25 1
      app/Models/Chat.php
  14. 15 0
      app/Models/JobBatch.php
  15. 31 1
      app/Models/Message.php
  16. 6 0
      app/Models/User.php
  17. 6 3
      app/Providers/AppServiceProvider.php
  18. 29 0
      app/Services/MessageService.php
  19. 3 0
      bootstrap/app.php
  20. 2 0
      composer.json
  21. 1082 13
      composer.lock
  22. 82 0
      config/broadcasting.php
  23. 46 0
      config/horizon.php
  24. 95 0
      config/reverb.php
  25. 1 2
      database/migrations/0001_01_01_000000_create_users_table.php
  26. 4 2
      database/migrations/2025_11_09_131804_create_chats_table.php
  27. 8 3
      database/migrations/2025_11_09_131811_create_messages_table.php
  28. 31 0
      database/migrations/2025_11_11_212131_create_batchables_table.php
  29. 0 12
      docker-compose.traefik.yml
  30. 6 1
      docker-compose.yml
  31. 240 0
      package-lock.json
  32. 4 0
      package.json
  33. 6 0
      resources/css/app.css
  34. 28 0
      resources/js/Components/AnimatedDots.vue
  35. 36 24
      resources/js/Components/AppSidebar.vue
  36. 69 0
      resources/js/Components/ChatInput.vue
  37. 72 0
      resources/js/Components/Message.vue
  38. 83 0
      resources/js/Composables/useInferenceResponse.js
  39. 12 10
      resources/js/Layouts/AppLayout.vue
  40. 21 0
      resources/js/Packages/Shadcn/Components/ui/alert/Alert.vue
  41. 17 0
      resources/js/Packages/Shadcn/Components/ui/alert/AlertDescription.vue
  42. 17 0
      resources/js/Packages/Shadcn/Components/ui/alert/AlertTitle.vue
  43. 26 0
      resources/js/Packages/Shadcn/Components/ui/alert/index.ts
  44. 26 0
      resources/js/Packages/Shadcn/Components/ui/badge/Badge.vue
  45. 26 0
      resources/js/Packages/Shadcn/Components/ui/badge/index.ts
  46. 22 0
      resources/js/Packages/Shadcn/Components/ui/card/Card.vue
  47. 17 0
      resources/js/Packages/Shadcn/Components/ui/card/CardAction.vue
  48. 17 0
      resources/js/Packages/Shadcn/Components/ui/card/CardContent.vue
  49. 17 0
      resources/js/Packages/Shadcn/Components/ui/card/CardDescription.vue
  50. 17 0
      resources/js/Packages/Shadcn/Components/ui/card/CardFooter.vue
  51. 17 0
      resources/js/Packages/Shadcn/Components/ui/card/CardHeader.vue
  52. 17 0
      resources/js/Packages/Shadcn/Components/ui/card/CardTitle.vue
  53. 7 0
      resources/js/Packages/Shadcn/Components/ui/card/index.ts
  54. 26 0
      resources/js/Packages/Shadcn/Components/ui/label/Label.vue
  55. 1 0
      resources/js/Packages/Shadcn/Components/ui/label/index.ts
  56. 17 0
      resources/js/Packages/Shadcn/Components/ui/spinner/Spinner.vue
  57. 1 0
      resources/js/Packages/Shadcn/Components/ui/spinner/index.ts
  58. 81 0
      resources/js/Pages/Auth/Login.vue
  59. 0 83
      resources/js/Pages/Chat.vue
  60. 22 0
      resources/js/Pages/Chat/Create.vue
  61. 68 0
      resources/js/Pages/Chat/View.vue
  62. 18 0
      resources/js/bootstrap.js
  63. 17 0
      routes/auth.php
  64. 11 0
      routes/channels.php
  65. 18 7
      routes/web.php

+ 14 - 0
app/Enums/Status.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Enums;
+
+enum Status: int
+{
+    case Loading = 0;
+    case Sending = 1;
+    case Thinking = 2;
+    case Typing = 3;
+    case Completed = 4;
+    case Canceled = 5;
+    case Failed = 6;
+}

+ 42 - 0
app/Events/Streaming.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Events;
+
+use App\Models\Message;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class Streaming implements ShouldBroadcastNow
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public readonly string $chunk;
+    public readonly int $index;
+
+    /**
+     * Create a new event instance.
+     */
+    public function __construct(private readonly Message $message, string $chunk, int $index)
+    {
+        $this->chunk = $chunk;
+        $this->index = $index;
+    }
+
+    /**
+     * Get the channels the event should broadcast on.
+     *
+     * @return array<int, \Illuminate\Broadcasting\Channel>
+     */
+    public function broadcastOn(): array
+    {
+        return [new PrivateChannel('App.Models.Message.' . $this->message->id)];
+    }
+
+    public function broadcastAs(): string
+    {
+        return 'Streaming';
+    }
+}

+ 41 - 0
app/Events/Wisper.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Events;
+
+use App\Enums\Status;
+use App\Models\Message;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class Wisper implements ShouldBroadcastNow
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public readonly Status $status;
+
+    /**
+     * Create a new event instance.
+     */
+    public function __construct(private readonly Message $message, Status $status)
+    {
+        $this->status = $status;
+    }
+
+    /**
+     * Get the channels the event should broadcast on.
+     *
+     * @return array<int, \Illuminate\Broadcasting\Channel>
+     */
+    public function broadcastOn(): array
+    {
+        return [new PrivateChannel('App.Models.Message.' . $this->message->id)];
+    }
+
+    public function broadcastAs(): string
+    {
+        return 'Wisper';
+    }
+}

+ 100 - 0
app/Helpers/ChunkParser.php

@@ -0,0 +1,100 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Helpers;
+
+use App\Enums\Status;
+
+final class ChunkParser
+{
+    public string $chunk = '';
+    private string $buffer = '';
+
+    private array $results = [];
+
+    public function __construct(private readonly array $tags = ['think', 'content', 'fields'], private readonly string $name = 'inference_rag_')
+    {
+        foreach ($this->tags as $tag) {
+            $this->results[$tag] = null;
+        }
+    }
+
+    public function append(string $chunk): self
+    {
+        $this->chunk = $chunk;
+        $this->buffer .= $chunk;
+        return $this;
+    }
+
+    public function parse(): self
+    {
+        if ($this->buffer === '') {
+            return $this;
+        }
+
+        foreach ($this->tags as $tag) {
+            $positions = $this->positions($this->buffer, $tag);
+
+            if (count($positions) >= 1) {
+                $length = strlen('<|' . $this->name . $tag . '|>');
+                $content = substr($this->buffer, $positions[0] + $length, ($positions[1] ?? null) - $positions[0] - $length);
+
+                $this->results[$tag] = $content;
+            } else {
+                $this->results[$tag] = null;
+            }
+        }
+
+        return $this;
+    }
+
+    private function positions(string $buffer, string $tag): array
+    {
+        $pattern = "/<\\|" . $this->name . $tag . "\\|>/";
+        $matches = [];
+        preg_match_all($pattern, $buffer, $matches, PREG_OFFSET_CAPTURE);
+
+        return !empty($matches[0]) ? array_column($matches[0], 1) : [];
+    }
+
+    public function all(): array
+    {
+        return $this->results;
+    }
+
+    public function toArray(): array
+    {
+        return ['think' => $this->think, 'content' => $this->content, 'fields' => $this->fields, 'status' => $this->getStatus()];
+    }
+
+    private function getStatus(): ?Status
+    {
+        if ($this->think && !$this->content) {
+            return Status::Thinking;
+        }
+
+        if ($this->content) {
+            return Status::Typing;
+        }
+
+        return null;
+    }
+
+    public function __get(string $tag)
+    {
+        if($tag === 'status') {
+            return $this->getStatus();
+        }
+
+        $string = $this->results[$tag] ?? null;
+        if ($tag === 'fields') {
+            try {
+                return json_decode($string, true);
+            } catch (\Throwable) {
+                return [];
+            }
+        }
+
+        return $string;
+    }
+}

+ 52 - 0
app/Http/Controllers/Auth/AuthenticatedSessionController.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\Auth\LoginRequest;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Route;
+use Illuminate\Validation\ValidationException;
+use Inertia\Inertia;
+use Inertia\Response;
+
+class AuthenticatedSessionController extends Controller
+{
+    /**
+     * Display the login view.
+     */
+    public function create(): Response
+    {
+        return Inertia::render('Auth/Login', [
+            'canResetPassword' => Route::has('password.request'),
+            'status' => session('status'),
+        ]);
+    }
+
+    /**
+     * Handle an incoming authentication request.
+     * @throws ValidationException
+     */
+    public function store(LoginRequest $request): RedirectResponse
+    {
+        $request->authenticate();
+        $request->session()->regenerate();
+
+        return redirect()->intended(route('index', absolute: false));
+    }
+
+    /**
+     * Destroy an authenticated session.
+     */
+    public function destroy(Request $request): RedirectResponse
+    {
+        Auth::guard('web')->logout();
+
+        $request->session()->invalidate();
+        $request->session()->regenerateToken();
+
+        return redirect()->route('index');
+    }
+}

+ 25 - 34
app/Http/Controllers/ChatController.php

@@ -2,53 +2,44 @@
 
 namespace App\Http\Controllers;
 
+use App\Jobs\SetChatTitleJob;
 use App\Models\Chat;
+use App\Services\MessageService;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Bus;
 
 class ChatController extends Controller
 {
-    public function index(?Chat $chat = null)
+    /**
+     * @throws \Throwable
+     */
+    public function store(MessageService $service, Request $request)
     {
-        $chat?->load('messages');
+        $chat = $request->user()->chats()->create();
+        $service->create($chat, $request->string('message'));
 
-        return inertia("Chat", [
-            'chat' => $chat?->toResource(),
-            "chats" => fn() => Chat::all(),
-        ]);
+        $batch = Bus::batch([new SetChatTitleJob($chat)])
+            ->name('Generate Chat Title')
+            ->onQueue('secondary')
+            ->dispatch();
+
+        $chat->batches()->attach($batch->id);
+
+        return redirect()->route('chats.show', $chat->id);
     }
 
-    public function message(Request $request)
+    public function create()
     {
-        $chat = $request->filled('uuid') ? Chat::find($request->input('uuid')) : Chat::create();
-        $chat->messages()->create(['text' => $request->input('message')]);
-
-        //TODO: Send To LLM
+        return inertia('Chat/Create');
+    }
 
-        $response = Http::inference()->post('/chat', [
-            'prompt' => $request->input('message')
-        ]);
+    public function show(Chat $chat)
+    {
+        $chat->load('messages');
 
-        $result = $response->json();
-        $chat->messages()->create([
-            'text' => $result['message']['content'],
-            'thinking' => $result['message']['thinking'],
-            'from' => $result['message']['role']
+        return inertia('Chat/View', [
+            'chat' => fn() => $chat->toResource(),
         ]);
-
-        if (!$chat->title) {
-            $response = Http::inference()->post('/generate', [
-                'prompt' => 'На основе первого сообщения пользователя предложи короткое название для чата длиной 2-4 слова.' . ' ' .
-                    'Название должно быть ёмким, отражать суть сообщения, без лишних слов.' . ' ' .
-                    'Ответ дай только в виде текста названия, без пояснений и кавычек.' . ' ' .
-                    'Первое сообщение: ' . $request->input('message')
-            ]);
-
-            $result = $response->json();
-            $chat->update(['title' => $result['response']]);
-        }
-
-        return redirect()->route('chats.view', ['chat' => $chat->id]);
     }
 
     public function destroy(Chat $chat)

+ 20 - 0
app/Http/Controllers/MessageController.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Chat;
+use App\Services\MessageService;
+use Illuminate\Http\Request;
+
+class MessageController extends Controller
+{
+    /**
+     * @throws \Throwable
+     */
+    public function store(MessageService $service, Chat $chat, Request $request)
+    {
+        $service->create($chat, $request->string('message'));
+
+        return redirect()->back();
+    }
+}

+ 13 - 1
app/Http/Middleware/HandleInertiaRequests.php

@@ -35,9 +35,21 @@ class HandleInertiaRequests extends Middleware
      */
     public function share(Request $request): array
     {
+        $user = $request->user() ?? null;
+        $user?->load('chats');
         return [
             ...parent::share($request),
-            //
+            'app' => [
+                'title' => config('app.name'),
+            ],
+            'flush' => [
+                'success' => session('success'),
+                'json' => session('json')
+            ],
+            'auth' => [
+                'user' => $user,
+                'chats' => fn() => $user?->chats->toResourceCollection(),
+            ]
         ];
     }
 }

+ 88 - 0
app/Http/Requests/Auth/LoginRequest.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Http\Requests\Auth;
+
+use Illuminate\Auth\Events\Lockout;
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Support\Str;
+use Illuminate\Validation\ValidationException;
+
+class LoginRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array<string, Rule|array|string>
+     */
+    public function rules(): array
+    {
+        return [
+            'email' => ['required', 'email'],
+            'password' => ['required', 'string'],
+        ];
+    }
+
+    /**
+     * Attempt to authenticate the request's credentials.
+     *
+     * @throws ValidationException
+     */
+    public function authenticate(): void
+    {
+        $this->ensureIsNotRateLimited();
+        if (!Auth::attempt($this->credentials(), $this->boolean('remember'))) {
+            RateLimiter::hit($this->throttleKey());
+            throw ValidationException::withMessages([
+                'email' => trans('auth.failed'),
+            ]);
+        }
+        RateLimiter::clear($this->throttleKey());
+    }
+
+    /**
+     * Ensure the login request is not rate limited.
+     *
+     * @throws ValidationException
+     */
+    public function ensureIsNotRateLimited(): void
+    {
+        if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
+            return;
+        }
+        event(new Lockout($this));
+        $seconds = RateLimiter::availableIn($this->throttleKey());
+        throw ValidationException::withMessages([
+            'email' => trans('auth.throttle', [
+                'seconds' => $seconds,
+                'minutes' => ceil($seconds / 60),
+            ]),
+        ]);
+    }
+
+    /**
+     * Get the rate limiting throttle key for the request.
+     */
+    public function throttleKey(): string
+    {
+        return Str::transliterate(Str::lower((string)$this->string('email')) . '|' . $this->ip());
+    }
+
+    protected function credentials(): array
+    {
+        return [
+            'email' => $this->email,
+            'password' => $this->password,
+        ];
+    }
+}

+ 9 - 1
app/Http/Resources/ChatResource.php

@@ -14,9 +14,17 @@ class ChatResource extends JsonResource
      */
     public function toArray(Request $request): array
     {
+        $title = $this->title;
+        if(!$title && $this->created_at->diffInMinutes(now()) > 5) {
+            $title = 'Новый чат';
+        }
+
         return array_merge(parent::toArray($request), [
-            'title' => $this->title ?? 'Новый чат',
+            'title' => $title,
             'messages' => MessageResource::collection($this->whenLoaded('messages')),
+
+            'created_at' => $this->created_at?->format('Y.m.d H:i:s'),
+            'updated_at' => $this->updated_at?->format('Y.m.d H:i:s')
         ]);
     }
 }

+ 92 - 0
app/Jobs/AskQuestionJob.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Enums\Status;
+use App\Events\Streaming;
+use App\Events\Wisper;
+use App\Helpers\ChunkParser;
+use App\Models\Message;
+use GuzzleHttp\Psr7\Utils;
+use Illuminate\Bus\Batchable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Http;
+
+class AskQuestionJob implements ShouldQueue
+{
+    use Batchable;
+    use Queueable;
+
+    public int $tries = 5;
+
+    private readonly Message $message;
+    private readonly string $question;
+
+    /**
+     * Create a new job instance.
+     */
+    public function __construct(Message $message, string $question)
+    {
+        $this->onQueue('primary');
+        $this->afterCommit();
+
+        $this->message = $message;
+        $this->question = $question;
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle(ChunkParser $response): void
+    {
+        //TODO: ; For Status
+        if ($this->batch()->cancelled()) {
+            $this->status(Status::Canceled);
+            return;
+        }
+
+        $this->status(Status::Sending);
+
+        /** @var \Illuminate\Http\Client\Response $stream */
+        $stream = Http::inference()
+            ->withOptions(['stream' => true])
+            ->post('/answer', ['message' => $this->question, 'history' => []]);
+
+        $body = $stream->toPsrResponse()->getBody();
+        for ($i = 0; !$body->eof(); $i++) {
+            $response->append(Utils::readLine($body))
+                ->parse();
+
+            if ($status = $response->status) {
+                $this->status($status);
+            }
+
+            $this->chunk($i, $response);
+        }
+
+        $this->status(Status::Completed);
+        $this->message->update(['content' => $response->content, 'thinking' => $response->think, 'fields' => $response->fields]);
+    }
+
+    private function status(Status $status): void
+    {
+        $this->message->update(['status' => $status]);
+        event(new Wisper($this->message, $status));
+    }
+
+    private function chunk(int $index, ChunkParser $response): void
+    {
+        try {
+            event(new Streaming($this->message, $response->chunk, $index));
+//            Model::withoutBroadcasting(fn() => $this->message->update(['content' => $response->content, 'thinking' => $response->think, 'fields' => $response->fields]));
+        } catch (\Exception $e) {
+            report($e);
+        }
+    }
+
+    public function failed(\Throwable $exception): void
+    {
+        $this->status(Status::Failed);
+    }
+}

+ 47 - 0
app/Jobs/SetChatTitleJob.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\Chat;
+use Illuminate\Bus\Batchable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Http;
+
+class SetChatTitleJob implements ShouldQueue
+{
+    use Batchable;
+    use Queueable;
+
+    public int $tries = 5;
+
+    /**
+     * Create a new job instance.
+     */
+    public function __construct(private readonly Chat $chat)
+    {
+        $this->onQueue('secondary');
+        $this->afterCommit();
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle(): void
+    {
+        if ($this->batch()?->cancelled() || $this->chat->title) {
+            return;
+        }
+
+        $response = Http::inference()->throw()->post('/generate', [
+            'message' => 'Кратко выдели суть обсуждения (2-4 слова) из сообщения пользователя для именования чата. ' . ' ' .
+                'Это необходимо, чтобы пользователь выдел список своих чатов и мог быстро понять что обсуждалось в данном чате.' . ' ' .
+                'Название должно быть ёмким, отражать суть сообщения, без лишних слов.' . ' ' .
+                'Ответ дай только в виде текста названия, без пояснений и кавычек.' . ' ' .
+                'Сообщение пользователя: ' . $this->chat->messages()->first()->content,
+        ]);
+
+        $result = $response->json();
+        $this->chat->update(['title' => $result['response']]);
+    }
+}

+ 25 - 1
app/Models/Chat.php

@@ -2,18 +2,42 @@
 
 namespace App\Models;
 
+use Illuminate\Database\Eloquent\BroadcastsEvents;
 use Illuminate\Database\Eloquent\Concerns\HasUuids;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
 
 class Chat extends Model
 {
     use HasUuids;
+    use BroadcastsEvents;
 
     protected $fillable = ['title', 'text'];
 
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
     public function messages(): HasMany
     {
-        return $this->hasMany(Message::class)->orderBy('created_at');
+        return $this->hasMany(Message::class)->orderBy('id');
+    }
+
+    public function batches(): MorphToMany
+    {
+        return $this->morphToMany(JobBatch::class, 'batchable', relatedPivotKey: 'batch_id')->withPivot('type');
     }
+
+    public function broadcastOn(string $event): array
+    {
+        return [$this, $this->user];
+    }
+
+//    public function broadcastWith(string $event): array
+//    {
+//        return ['id' => $this->id, 'title' => $this->title];
+//    }
 }

+ 15 - 0
app/Models/JobBatch.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Concerns\HasUlids;
+use Illuminate\Database\Eloquent\Model;
+
+class JobBatch extends Model
+{
+    use HasUlids;
+
+    protected $table = 'job_batches';
+    public $incrementing = false;
+    protected $keyType = 'string';
+}

+ 31 - 1
app/Models/Message.php

@@ -2,15 +2,45 @@
 
 namespace App\Models;
 
+use App\Enums\Status;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
+use Illuminate\Database\Eloquent\Concerns\HasUuids;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
 
 class Message extends Model
 {
-    protected $fillable = ['text', 'thinking', 'from'];
+    use HasUuids;
+    use BroadcastsEvents;
+
+    protected $fillable = ['content', 'thinking', 'from', 'status', 'fields'];
+
+    public function casts(): array
+    {
+        return [
+            'feidls' => 'array',
+            'status' => Status::class,
+        ];
+    }
+
+    public function batches(): MorphToMany
+    {
+        return $this->morphToMany(JobBatch::class, 'batchable')->withPivot('type');
+    }
 
     public function chat(): BelongsTo
     {
         return $this->belongsTo(Chat::class);
     }
+
+    public function broadcastOn(string $event): array
+    {
+        return [$this, $this->chat];
+    }
+
+    public function broadcastWith(string $event): array
+    {
+        return ['id' => $this->id, 'from' => $this->from, 'status' => $this->status];
+    }
 }

+ 6 - 0
app/Models/User.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 // use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
 
@@ -45,4 +46,9 @@ class User extends Authenticatable
             'password' => 'hashed',
         ];
     }
+
+    public function chats(): HasMany
+    {
+        return $this->hasMany(Chat::class)->orderBy('created_at', 'desc');
+    }
 }

+ 6 - 3
app/Providers/AppServiceProvider.php

@@ -2,8 +2,10 @@
 
 namespace App\Providers;
 
+use App\Models\Chat;
+use App\Models\Message;
 use Carbon\CarbonImmutable;
-//use Illuminate\Support\Facades\Data;
+use Illuminate\Support\Facades\Date;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\Relation;
 use Illuminate\Http\Resources\Json\JsonResource;
@@ -28,7 +30,7 @@ class AppServiceProvider extends ServiceProvider
      */
     public function boot(): void
     {
-//        Data::use(CarbonImmutable::class);
+        Date::use(CarbonImmutable::class);
 
         // As there are concerned with application correctness
         // leave them enable all the time.
@@ -40,7 +42,8 @@ class AppServiceProvider extends ServiceProvider
         Model::preventLazyLoading(!$this->app->isProduction());
 
         Relation::enforceMorphMap([
-            // TODO
+            'chat' => Chat::class,
+            'message' => Message::class
         ]);
 
         JsonResource::withoutWrapping();

+ 29 - 0
app/Services/MessageService.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\Status;
+use App\Jobs\AskQuestionJob;
+use App\Models\Chat;
+use App\Models\Message;
+use Illuminate\Support\Facades\Bus;
+
+class MessageService
+{
+    /**
+     * @throws \Throwable
+     */
+    public function create(Chat $chat, string $content, string $sender = 'user'): void
+    {
+        $message = new Message(['from' => $sender, 'content' => $content]);
+        $chat->messages()->save($message);
+
+        $answer = $chat->messages()->create(['from' => 'assistant', 'status' => Status::Loading]);
+        $batch = Bus::batch([new AskQuestionJob($answer, $content)])
+            ->name('Ask Model')
+            ->onQueue('primary')
+            ->dispatch();
+
+        $chat->batches()->attach($batch->id);
+    }
+}

+ 3 - 0
bootstrap/app.php

@@ -11,9 +11,12 @@ return Application::configure(basePath: dirname(__DIR__))
     ->withRouting(
         web: __DIR__.'/../routes/web.php',
         commands: __DIR__.'/../routes/console.php',
+        channels: __DIR__.'/../routes/channels.php',
         health: '/up',
     )
     ->withMiddleware(function (Middleware $middleware) {
+        $middleware->statefulApi();
+
         $middleware->append(AssignTraceId::class);
         $middleware->web(append: [
             HandleInertiaRequests::class,

+ 2 - 0
composer.json

@@ -11,6 +11,8 @@
         "laravel/framework": "^12.0",
         "laravel/horizon": "^5.23",
         "laravel/octane": "^2.3",
+        "laravel/reverb": "^1.0",
+        "laravel/sanctum": "^4.2",
         "laravel/tinker": "^2.9",
         "sentry/sentry-laravel": "^4.3",
         "spatie/laravel-ignition": "^2.9",

+ 1082 - 13
composer.lock

@@ -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": "ea8fc5c025ffad90fab4e4967609f464",
+    "content-hash": "7ada324c7316ad053510dcbf2d0ac7d5",
     "packages": [
         {
             "name": "brick/math",
@@ -135,6 +135,136 @@
             ],
             "time": "2024-02-09T16:56:22+00:00"
         },
+        {
+            "name": "clue/redis-protocol",
+            "version": "v0.3.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/clue/redis-protocol.git",
+                "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c",
+                "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Clue\\Redis\\Protocol\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@lueck.tv"
+                }
+            ],
+            "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.",
+            "homepage": "https://github.com/clue/redis-protocol",
+            "keywords": [
+                "parser",
+                "protocol",
+                "redis",
+                "resp",
+                "serializer",
+                "streaming"
+            ],
+            "support": {
+                "issues": "https://github.com/clue/redis-protocol/issues",
+                "source": "https://github.com/clue/redis-protocol/tree/v0.3.2"
+            },
+            "funding": [
+                {
+                    "url": "https://clue.engineering/support",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/clue",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-08-07T11:06:28+00:00"
+        },
+        {
+            "name": "clue/redis-react",
+            "version": "v2.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/clue/reactphp-redis.git",
+                "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca",
+                "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca",
+                "shasum": ""
+            },
+            "require": {
+                "clue/redis-protocol": "^0.3.2",
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.0 || ^1.1",
+                "react/promise-timer": "^1.11",
+                "react/socket": "^1.16"
+            },
+            "require-dev": {
+                "clue/block-react": "^1.5",
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Clue\\React\\Redis\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering"
+                }
+            ],
+            "description": "Async Redis client implementation, built on top of ReactPHP.",
+            "homepage": "https://github.com/clue/reactphp-redis",
+            "keywords": [
+                "async",
+                "client",
+                "database",
+                "reactphp",
+                "redis"
+            ],
+            "support": {
+                "issues": "https://github.com/clue/reactphp-redis/issues",
+                "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0"
+            },
+            "funding": [
+                {
+                    "url": "https://clue.engineering/support",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/clue",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-01-03T16:18:33+00:00"
+        },
         {
             "name": "dflydev/dot-access-data",
             "version": "v3.0.3",
@@ -508,6 +638,53 @@
             ],
             "time": "2025-03-06T22:45:56+00:00"
         },
+        {
+            "name": "evenement/evenement",
+            "version": "v3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/igorw/evenement.git",
+                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
+                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9 || ^6"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Evenement\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Igor Wiedler",
+                    "email": "igor@wiedler.ch"
+                }
+            ],
+            "description": "Événement is a very simple event dispatching library for PHP",
+            "keywords": [
+                "event-dispatcher",
+                "event-emitter"
+            ],
+            "support": {
+                "issues": "https://github.com/igorw/evenement/issues",
+                "source": "https://github.com/igorw/evenement/tree/v3.0.2"
+            },
+            "time": "2023-08-08T05:53:35+00:00"
+        },
         {
             "name": "fruitcake/php-cors",
             "version": "v1.3.0",
@@ -1718,6 +1895,152 @@
             },
             "time": "2025-09-19T13:47:56+00:00"
         },
+        {
+            "name": "laravel/reverb",
+            "version": "v1.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laravel/reverb.git",
+                "reference": "728f6e2c779dae844c2862ea060ca32db5b6b495"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laravel/reverb/zipball/728f6e2c779dae844c2862ea060ca32db5b6b495",
+                "reference": "728f6e2c779dae844c2862ea060ca32db5b6b495",
+                "shasum": ""
+            },
+            "require": {
+                "clue/redis-react": "^2.6",
+                "guzzlehttp/psr7": "^2.6",
+                "illuminate/console": "^10.47|^11.0|^12.0",
+                "illuminate/contracts": "^10.47|^11.0|^12.0",
+                "illuminate/http": "^10.47|^11.0|^12.0",
+                "illuminate/support": "^10.47|^11.0|^12.0",
+                "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0",
+                "php": "^8.2",
+                "pusher/pusher-php-server": "^7.2",
+                "ratchet/rfc6455": "^0.4",
+                "react/promise-timer": "^1.10",
+                "react/socket": "^1.14",
+                "symfony/console": "^6.0|^7.0",
+                "symfony/http-foundation": "^6.3|^7.0"
+            },
+            "require-dev": {
+                "orchestra/testbench": "^8.0|^9.0|^10.0",
+                "pestphp/pest": "^2.0|^3.0",
+                "phpstan/phpstan": "^1.10",
+                "ratchet/pawl": "^0.4.1",
+                "react/async": "^4.2",
+                "react/http": "^1.9"
+            },
+            "type": "library",
+            "extra": {
+                "laravel": {
+                    "aliases": {
+                        "Output": "Laravel\\Reverb\\Output"
+                    },
+                    "providers": [
+                        "Laravel\\Reverb\\ApplicationManagerServiceProvider",
+                        "Laravel\\Reverb\\ReverbServiceProvider"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Laravel\\Reverb\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Taylor Otwell",
+                    "email": "taylor@laravel.com"
+                },
+                {
+                    "name": "Joe Dixon",
+                    "email": "joe@laravel.com"
+                }
+            ],
+            "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.",
+            "keywords": [
+                "WebSockets",
+                "laravel",
+                "real-time",
+                "websocket"
+            ],
+            "support": {
+                "issues": "https://github.com/laravel/reverb/issues",
+                "source": "https://github.com/laravel/reverb/tree/v1.6.0"
+            },
+            "time": "2025-09-07T23:21:05+00:00"
+        },
+        {
+            "name": "laravel/sanctum",
+            "version": "v4.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laravel/sanctum.git",
+                "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
+                "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "illuminate/console": "^11.0|^12.0",
+                "illuminate/contracts": "^11.0|^12.0",
+                "illuminate/database": "^11.0|^12.0",
+                "illuminate/support": "^11.0|^12.0",
+                "php": "^8.2",
+                "symfony/console": "^7.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "^1.6",
+                "orchestra/testbench": "^9.0|^10.0",
+                "phpstan/phpstan": "^1.10",
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "laravel": {
+                    "providers": [
+                        "Laravel\\Sanctum\\SanctumServiceProvider"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Laravel\\Sanctum\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Taylor Otwell",
+                    "email": "taylor@laravel.com"
+                }
+            ],
+            "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
+            "keywords": [
+                "auth",
+                "laravel",
+                "sanctum"
+            ],
+            "support": {
+                "issues": "https://github.com/laravel/sanctum/issues",
+                "source": "https://github.com/laravel/sanctum"
+            },
+            "time": "2025-07-09T19:45:24+00:00"
+        },
         {
             "name": "laravel/serializable-closure",
             "version": "v2.0.6",
@@ -2981,6 +3304,102 @@
             ],
             "time": "2024-09-09T07:06:30+00:00"
         },
+        {
+            "name": "paragonie/sodium_compat",
+            "version": "v2.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/paragonie/sodium_compat.git",
+                "reference": "547e2dc4d45107440e76c17ab5a46e4252460158"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/547e2dc4d45107440e76c17ab5a46e4252460158",
+                "reference": "547e2dc4d45107440e76c17ab5a46e4252460158",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.1",
+                "php-64bit": "*"
+            },
+            "require-dev": {
+                "infection/infection": "^0",
+                "nikic/php-fuzzer": "^0",
+                "phpunit/phpunit": "^7|^8|^9|^10|^11",
+                "vimeo/psalm": "^4|^5|^6"
+            },
+            "suggest": {
+                "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security."
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "autoload.php"
+                ],
+                "psr-4": {
+                    "ParagonIE\\Sodium\\": "namespaced/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "ISC"
+            ],
+            "authors": [
+                {
+                    "name": "Paragon Initiative Enterprises",
+                    "email": "security@paragonie.com"
+                },
+                {
+                    "name": "Frank Denis",
+                    "email": "jedisct1@pureftpd.org"
+                }
+            ],
+            "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists",
+            "keywords": [
+                "Authentication",
+                "BLAKE2b",
+                "ChaCha20",
+                "ChaCha20-Poly1305",
+                "Chapoly",
+                "Curve25519",
+                "Ed25519",
+                "EdDSA",
+                "Edwards-curve Digital Signature Algorithm",
+                "Elliptic Curve Diffie-Hellman",
+                "Poly1305",
+                "Pure-PHP cryptography",
+                "RFC 7748",
+                "RFC 8032",
+                "Salpoly",
+                "Salsa20",
+                "X25519",
+                "XChaCha20-Poly1305",
+                "XSalsa20-Poly1305",
+                "Xchacha20",
+                "Xsalsa20",
+                "aead",
+                "cryptography",
+                "ecdh",
+                "elliptic curve",
+                "elliptic curve cryptography",
+                "encryption",
+                "libsodium",
+                "php",
+                "public-key cryptography",
+                "secret-key cryptography",
+                "side-channel resistant"
+            ],
+            "support": {
+                "issues": "https://github.com/paragonie/sodium_compat/issues",
+                "source": "https://github.com/paragonie/sodium_compat/tree/v2.4.0"
+            },
+            "time": "2025-10-06T08:47:40+00:00"
+        },
         {
             "name": "phpoption/phpoption",
             "version": "1.9.4",
@@ -3548,30 +3967,91 @@
             "time": "2025-10-27T17:15:31+00:00"
         },
         {
-            "name": "ralouphie/getallheaders",
-            "version": "3.0.3",
+            "name": "pusher/pusher-php-server",
+            "version": "7.2.7",
             "source": {
                 "type": "git",
-                "url": "https://github.com/ralouphie/getallheaders.git",
-                "reference": "120b605dfeb996808c31b6477290a714d356e822"
+                "url": "https://github.com/pusher/pusher-http-php.git",
+                "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
-                "reference": "120b605dfeb996808c31b6477290a714d356e822",
+                "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7",
+                "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.6"
+                "ext-curl": "*",
+                "ext-json": "*",
+                "guzzlehttp/guzzle": "^7.2",
+                "paragonie/sodium_compat": "^1.6|^2.0",
+                "php": "^7.3|^8.0",
+                "psr/log": "^1.0|^2.0|^3.0"
             },
             "require-dev": {
-                "php-coveralls/php-coveralls": "^2.1",
-                "phpunit/phpunit": "^5 || ^6.5"
+                "overtrue/phplint": "^2.3",
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
-            "autoload": {
-                "files": [
-                    "src/getallheaders.php"
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Pusher\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Library for interacting with the Pusher REST API",
+            "keywords": [
+                "events",
+                "messaging",
+                "php-pusher-server",
+                "publish",
+                "push",
+                "pusher",
+                "real time",
+                "real-time",
+                "realtime",
+                "rest",
+                "trigger"
+            ],
+            "support": {
+                "issues": "https://github.com/pusher/pusher-http-php/issues",
+                "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7"
+            },
+            "time": "2025-01-06T10:56:20+00:00"
+        },
+        {
+            "name": "ralouphie/getallheaders",
+            "version": "3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ralouphie/getallheaders.git",
+                "reference": "120b605dfeb996808c31b6477290a714d356e822"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+                "reference": "120b605dfeb996808c31b6477290a714d356e822",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.6"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.1",
+                "phpunit/phpunit": "^5 || ^6.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/getallheaders.php"
                 ]
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -3745,6 +4225,595 @@
             },
             "time": "2025-09-04T20:59:21+00:00"
         },
+        {
+            "name": "ratchet/rfc6455",
+            "version": "v0.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ratchetphp/RFC6455.git",
+                "reference": "859d95f85dda0912c6d5b936d036d044e3af47ef"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/859d95f85dda0912c6d5b936d036d044e3af47ef",
+                "reference": "859d95f85dda0912c6d5b936d036d044e3af47ef",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.4",
+                "psr/http-factory-implementation": "^1.0",
+                "symfony/polyfill-php80": "^1.15"
+            },
+            "require-dev": {
+                "guzzlehttp/psr7": "^2.7",
+                "phpunit/phpunit": "^9.5",
+                "react/socket": "^1.3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Ratchet\\RFC6455\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Matt Bonneau",
+                    "role": "Developer"
+                }
+            ],
+            "description": "RFC6455 WebSocket protocol handler",
+            "homepage": "http://socketo.me",
+            "keywords": [
+                "WebSockets",
+                "rfc6455",
+                "websocket"
+            ],
+            "support": {
+                "chat": "https://gitter.im/reactphp/reactphp",
+                "issues": "https://github.com/ratchetphp/RFC6455/issues",
+                "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.0"
+            },
+            "time": "2025-02-24T01:18:22+00:00"
+        },
+        {
+            "name": "react/cache",
+            "version": "v1.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/cache.git",
+                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
+                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0",
+                "react/promise": "^3.0 || ^2.0 || ^1.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async, Promise-based cache interface for ReactPHP",
+            "keywords": [
+                "cache",
+                "caching",
+                "promise",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/cache/issues",
+                "source": "https://github.com/reactphp/cache/tree/v1.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2022-11-30T15:59:55+00:00"
+        },
+        {
+            "name": "react/dns",
+            "version": "v1.13.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/dns.git",
+                "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5",
+                "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0",
+                "react/cache": "^1.0 || ^0.6 || ^0.5",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.7 || ^1.2.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/async": "^4.3 || ^3 || ^2",
+                "react/promise-timer": "^1.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Dns\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async DNS resolver for ReactPHP",
+            "keywords": [
+                "async",
+                "dns",
+                "dns-resolver",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/dns/issues",
+                "source": "https://github.com/reactphp/dns/tree/v1.13.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2024-06-13T14:18:03+00:00"
+        },
+        {
+            "name": "react/event-loop",
+            "version": "v1.5.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/event-loop.git",
+                "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354",
+                "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "suggest": {
+                "ext-pcntl": "For signal handling support when using the StreamSelectLoop"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\EventLoop\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
+            "keywords": [
+                "asynchronous",
+                "event-loop"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/event-loop/issues",
+                "source": "https://github.com/reactphp/event-loop/tree/v1.5.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2023-11-13T13:48:05+00:00"
+        },
+        {
+            "name": "react/promise",
+            "version": "v3.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/promise.git",
+                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
+                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "1.12.28 || 1.4.10",
+                "phpunit/phpunit": "^9.6 || ^7.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions_include.php"
+                ],
+                "psr-4": {
+                    "React\\Promise\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "A lightweight implementation of CommonJS Promises/A for PHP",
+            "keywords": [
+                "promise",
+                "promises"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/promise/issues",
+                "source": "https://github.com/reactphp/promise/tree/v3.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-08-19T18:57:03+00:00"
+        },
+        {
+            "name": "react/promise-timer",
+            "version": "v1.11.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/promise-timer.git",
+                "reference": "4f70306ed66b8b44768941ca7f142092600fafc1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1",
+                "reference": "4f70306ed66b8b44768941ca7f142092600fafc1",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.7.0 || ^1.2.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions_include.php"
+                ],
+                "psr-4": {
+                    "React\\Promise\\Timer\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.",
+            "homepage": "https://github.com/reactphp/promise-timer",
+            "keywords": [
+                "async",
+                "event-loop",
+                "promise",
+                "reactphp",
+                "timeout",
+                "timer"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/promise-timer/issues",
+                "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2024-06-04T14:27:45+00:00"
+        },
+        {
+            "name": "react/socket",
+            "version": "v1.16.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/socket.git",
+                "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1",
+                "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.0",
+                "react/dns": "^1.13",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.6 || ^1.2.1",
+                "react/stream": "^1.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/async": "^4.3 || ^3.3 || ^2",
+                "react/promise-stream": "^1.4",
+                "react/promise-timer": "^1.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Socket\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
+            "keywords": [
+                "Connection",
+                "Socket",
+                "async",
+                "reactphp",
+                "stream"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/socket/issues",
+                "source": "https://github.com/reactphp/socket/tree/v1.16.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2024-07-26T10:38:09+00:00"
+        },
+        {
+            "name": "react/stream",
+            "version": "v1.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/stream.git",
+                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.8",
+                "react/event-loop": "^1.2"
+            },
+            "require-dev": {
+                "clue/stream-filter": "~1.2",
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Stream\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
+            "keywords": [
+                "event-driven",
+                "io",
+                "non-blocking",
+                "pipe",
+                "reactphp",
+                "readable",
+                "stream",
+                "writable"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/stream/issues",
+                "source": "https://github.com/reactphp/stream/tree/v1.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2024-06-11T12:45:25+00:00"
+        },
         {
             "name": "sentry/sentry",
             "version": "4.18.0",

+ 82 - 0
config/broadcasting.php

@@ -0,0 +1,82 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Default Broadcaster
+    |--------------------------------------------------------------------------
+    |
+    | This option controls the default broadcaster that will be used by the
+    | framework when an event needs to be broadcast. You may set this to
+    | any of the connections defined in the "connections" array below.
+    |
+    | Supported: "reverb", "pusher", "ably", "redis", "log", "null"
+    |
+    */
+
+    'default' => env('BROADCAST_CONNECTION', 'null'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Broadcast Connections
+    |--------------------------------------------------------------------------
+    |
+    | Here you may define all of the broadcast connections that will be used
+    | to broadcast events to other systems or over WebSockets. Samples of
+    | each available type of connection are provided inside this array.
+    |
+    */
+
+    'connections' => [
+
+        'reverb' => [
+            'driver' => 'reverb',
+            'key' => env('REVERB_APP_KEY'),
+            'secret' => env('REVERB_APP_SECRET'),
+            'app_id' => env('REVERB_APP_ID'),
+            'options' => [
+                'host' => env('REVERB_HOST'),
+                'port' => env('REVERB_PORT', 443),
+                'scheme' => env('REVERB_SCHEME', 'https'),
+                'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
+            ],
+            'client_options' => [
+                // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
+            ],
+        ],
+
+        'pusher' => [
+            'driver' => 'pusher',
+            'key' => env('PUSHER_APP_KEY'),
+            'secret' => env('PUSHER_APP_SECRET'),
+            'app_id' => env('PUSHER_APP_ID'),
+            'options' => [
+                'cluster' => env('PUSHER_APP_CLUSTER'),
+                'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
+                'port' => env('PUSHER_PORT', 443),
+                'scheme' => env('PUSHER_SCHEME', 'https'),
+                'encrypted' => true,
+                'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
+            ],
+            'client_options' => [
+                // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
+            ],
+        ],
+
+        'ably' => [
+            'driver' => 'ably',
+            'key' => env('ABLY_KEY'),
+        ],
+
+        'log' => [
+            'driver' => 'log',
+        ],
+
+        'null' => [
+            'driver' => 'null',
+        ],
+
+    ],
+
+];

+ 46 - 0
config/horizon.php

@@ -193,6 +193,32 @@ return [
             'timeout' => 60,
             'nice' => 0,
         ],
+        'supervisor-primary-1' => [
+            'connection' => 'redis',
+            'queue' => ['primary'],
+            'balance' => 'auto',
+            'autoScalingStrategy' => 'time',
+            'maxProcesses' => 1,
+            'maxTime' => 0,
+            'maxJobs' => 0,
+            'memory' => 128,
+            'tries' => 1,
+            'timeout' => 0,
+            'nice' => 0,
+        ],
+        'supervisor-secondary-1' => [
+            'connection' => 'redis',
+            'queue' => ['secondary'],
+            'balance' => 'auto',
+            'autoScalingStrategy' => 'time',
+            'maxProcesses' => 1,
+            'maxTime' => 0,
+            'maxJobs' => 0,
+            'memory' => 128,
+            'tries' => 1,
+            'timeout' => 60,
+            'nice' => 0,
+        ],
     ],
 
     'environments' => [
@@ -202,11 +228,31 @@ return [
                 'balanceMaxShift' => 1,
                 'balanceCooldown' => 3,
             ],
+            'supervisor-primary-1' => [
+                'maxProcesses' => 10,
+                'balanceMaxShift' => 1,
+                'balanceCooldown' => 3,
+            ],
+            'supervisor-secondary-1' => [
+                'maxProcesses' => 10,
+                'balanceMaxShift' => 1,
+                'balanceCooldown' => 3,
+            ],
         ],
 
         'local' => [
             'supervisor-1' => [
+                'maxProcesses' => 1,
+            ],
+            'supervisor-primary-1' => [
+                'maxProcesses' => 10,
+                'balanceMaxShift' => 1,
+                'balanceCooldown' => 3,
+            ],
+            'supervisor-secondary-1' => [
                 'maxProcesses' => 3,
+                'balanceMaxShift' => 1,
+                'balanceCooldown' => 3,
             ],
         ],
     ],

+ 95 - 0
config/reverb.php

@@ -0,0 +1,95 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Default Reverb Server
+    |--------------------------------------------------------------------------
+    |
+    | This option controls the default server used by Reverb to handle
+    | incoming messages as well as broadcasting message to all your
+    | connected clients. At this time only "reverb" is supported.
+    |
+    */
+
+    'default' => env('REVERB_SERVER', 'reverb'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Reverb Servers
+    |--------------------------------------------------------------------------
+    |
+    | Here you may define details for each of the supported Reverb servers.
+    | Each server has its own configuration options that are defined in
+    | the array below. You should ensure all the options are present.
+    |
+    */
+
+    'servers' => [
+
+        'reverb' => [
+            'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
+            'port' => env('REVERB_SERVER_PORT', 8080),
+            'path' => env('REVERB_SERVER_PATH', ''),
+            'hostname' => env('REVERB_HOST'),
+            'options' => [
+                'tls' => [],
+            ],
+            'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
+            'scaling' => [
+                'enabled' => env('REVERB_SCALING_ENABLED', false),
+                'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
+                'server' => [
+                    'url' => env('REDIS_URL'),
+                    'host' => env('REDIS_HOST', '127.0.0.1'),
+                    'port' => env('REDIS_PORT', '6379'),
+                    'username' => env('REDIS_USERNAME'),
+                    'password' => env('REDIS_PASSWORD'),
+                    'database' => env('REDIS_DB', '0'),
+                    'timeout' => env('REDIS_TIMEOUT', 60),
+                ],
+            ],
+            'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
+            'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
+        ],
+
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Reverb Applications
+    |--------------------------------------------------------------------------
+    |
+    | Here you may define how Reverb applications are managed. If you choose
+    | to use the "config" provider, you may define an array of apps which
+    | your server will support, including their connection credentials.
+    |
+    */
+
+    'apps' => [
+
+        'provider' => 'config',
+
+        'apps' => [
+            [
+                'key' => env('REVERB_APP_KEY'),
+                'secret' => env('REVERB_APP_SECRET'),
+                'app_id' => env('REVERB_APP_ID'),
+                'options' => [
+                    'host' => env('REVERB_HOST'),
+                    'port' => env('REVERB_PORT', 443),
+                    'scheme' => env('REVERB_SCHEME', 'https'),
+                    'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
+                ],
+                'allowed_origins' => ['*'],
+                'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
+                'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
+                'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
+                'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
+            ],
+        ],
+
+    ],
+
+];

+ 1 - 2
database/migrations/0001_01_01_000000_create_users_table.php

@@ -4,8 +4,7 @@ use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Support\Facades\Schema;
 
-return new class extends Migration
-{
+return new class extends Migration {
     /**
      * Run the migrations.
      */

+ 4 - 2
database/migrations/2025_11_09_131804_create_chats_table.php

@@ -1,11 +1,11 @@
 <?php
 
+use App\Models\User;
 use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Support\Facades\Schema;
 
-return new class extends Migration
-{
+return new class extends Migration {
     /**
      * Run the migrations.
      */
@@ -15,6 +15,8 @@ return new class extends Migration
             $table->uuid('id')->primary();
             $table->string('title')->nullable();
 
+            $table->foreignIdFor(User::class)->constrained()->cascadeOnUpdate()->cascadeOnDelete();
+
             $table->timestamps();
         });
     }

+ 8 - 3
database/migrations/2025_11_09_131811_create_messages_table.php

@@ -1,5 +1,6 @@
 <?php
 
+use App\Enums\Status;
 use App\Models\Chat;
 use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
@@ -12,17 +13,21 @@ return new class extends Migration {
     public function up(): void
     {
         Schema::create('messages', function (Blueprint $table) {
-            $table->id();
+            $table->uuid('id')->primary();
 
             $table->foreignIdFor(Chat::class)->constrained()->cascadeOnUpdate()->cascadeOnDelete();
 
-            $table->longText('text')->nullable();
+            $table->longText('thinking')->nullable();
+            $table->longText('content')->nullable();
+
             $table->string('from')->default('user');
 
             $table->integer('tokens_in')->default(0);
             $table->integer('tokens_out')->default(0);
 
-            $table->longText('thinking')->nullable();
+            $table->jsonb('fields')->nullable();
+
+            $table->tinyInteger('status')->default(Status::Completed);
 
             $table->timestamps();
         });

+ 31 - 0
database/migrations/2025_11_11_212131_create_batchables_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use App\Models\JobBatch;
+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::create('batchables', function (Blueprint $table) {
+            $table->id();
+            $table->foreignUlid('batch_id', 36)->constrained((new JobBatch)->getTable())->cascadeOnUpdate()->cascadeOnDelete();
+            $table->nullableUuidMorphs('batchable');
+
+            $table->string('type')->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('batchables');
+    }
+};

+ 0 - 12
docker-compose.traefik.yml

@@ -6,10 +6,6 @@ services:
       - "traefik.http.routers.application-http.rule=Host(`laravel.localhost`)"
       - "traefik.http.routers.application-http.service=application"
       - "traefik.http.services.application.loadbalancer.server.port=9000"
-      - "traefik.docker.network=proxy"
-    networks:
-      - default
-      - proxy
 
   vite:
     labels:
@@ -47,11 +43,3 @@ services:
       - /var/run/docker.sock:/var/run/docker.sock
       - .docker/traefik/configuration:/configuration/
       - .docker/traefik/certs:/etc/certs:ro
-    networks:
-      - default
-      - proxy
-
-networks:
-  proxy:
-    name: proxy
-    driver: bridge

+ 6 - 1
docker-compose.yml

@@ -32,7 +32,7 @@ services:
 
     reverb:
         <<: *app
-        command: php /app/artisan reverb:start --host=0.0.0.0 --port=8080
+        command: php /app/artisan reverb:start --host=0.0.0.0 --port=8080 --debug
         healthcheck:
             test: [ "CMD", "sh", "-c", "pgrep -f 'reverb:start' > /dev/null || exit 1" ]
             interval: 2s
@@ -51,3 +51,8 @@ services:
     development:
         <<: *app
         restart: no
+
+networks:
+    default:
+        name: rag
+        driver: bridge

+ 240 - 0
package-lock.json

@@ -9,6 +9,7 @@
                 "@tailwindcss/typography": "github:tailwindcss/typography",
                 "@vitejs/plugin-vue": "^6.0.1",
                 "@vueuse/core": "^14.0.0",
+                "buffer": "^6.0.3",
                 "chokidar": "^4.0.3",
                 "class-variance-authority": "^0.7.1",
                 "clsx": "^2.1.1",
@@ -22,8 +23,11 @@
                 "vue": "^3.5.24"
             },
             "devDependencies": {
+                "@laravel/echo-vue": "^2.2.6",
                 "@tailwindcss/vite": "^4.0.0",
                 "concurrently": "^9.0.1",
+                "laravel-echo": "^2.2.6",
+                "pusher-js": "^8.4.0",
                 "typescript": "^5.9.3",
                 "vite": "^7.2.2"
             }
@@ -645,6 +649,21 @@
                 "@jridgewell/sourcemap-codec": "^1.4.14"
             }
         },
+        "node_modules/@laravel/echo-vue": {
+            "version": "2.2.6",
+            "resolved": "https://registry.npmjs.org/@laravel/echo-vue/-/echo-vue-2.2.6.tgz",
+            "integrity": "sha512-nznzN1BYVYR+DQ/JLHvl4qKLioBhzQjGsscWeXKLLDNUgOkbZNpx8/tF+ycUtzEPB4fR88BNqBMXs5P60TvBrA==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=20"
+            },
+            "peerDependencies": {
+                "pusher-js": "*",
+                "socket.io-client": "*",
+                "vue": "^3.0.0"
+            }
+        },
         "node_modules/@rolldown/pluginutils": {
             "version": "1.0.0-beta.29",
             "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
@@ -937,6 +956,14 @@
                 "win32"
             ]
         },
+        "node_modules/@socket.io/component-emitter": {
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+            "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true
+        },
         "node_modules/@swc/helpers": {
             "version": "0.5.17",
             "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
@@ -1491,6 +1518,50 @@
                 "proxy-from-env": "^1.1.0"
             }
         },
+        "node_modules/base64-js": {
+            "version": "1.5.1",
+            "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+            "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ],
+            "license": "MIT"
+        },
+        "node_modules/buffer": {
+            "version": "6.0.3",
+            "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+            "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ],
+            "license": "MIT",
+            "dependencies": {
+                "base64-js": "^1.3.1",
+                "ieee754": "^1.2.1"
+            }
+        },
         "node_modules/call-bind-apply-helpers": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1676,6 +1747,25 @@
             "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
             "license": "MIT"
         },
+        "node_modules/debug": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
         "node_modules/defu": {
             "version": "6.1.4",
             "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
@@ -1722,6 +1812,32 @@
             "dev": true,
             "license": "MIT"
         },
+        "node_modules/engine.io-client": {
+            "version": "6.6.3",
+            "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
+            "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "@socket.io/component-emitter": "~3.1.0",
+                "debug": "~4.3.1",
+                "engine.io-parser": "~5.2.1",
+                "ws": "~8.17.1",
+                "xmlhttprequest-ssl": "~2.1.1"
+            }
+        },
+        "node_modules/engine.io-parser": {
+            "version": "5.2.3",
+            "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+            "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true,
+            "engines": {
+                "node": ">=10.0.0"
+            }
+        },
         "node_modules/enhanced-resolve": {
             "version": "5.18.3",
             "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -2041,6 +2157,26 @@
                 "node": ">= 0.4"
             }
         },
+        "node_modules/ieee754": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+            "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ],
+            "license": "BSD-3-Clause"
+        },
         "node_modules/is-fullwidth-code-point": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -2061,6 +2197,20 @@
                 "jiti": "lib/jiti-cli.mjs"
             }
         },
+        "node_modules/laravel-echo": {
+            "version": "2.2.6",
+            "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.2.6.tgz",
+            "integrity": "sha512-KuCldOrE8qbm0CVDBgc6FiX3VuReDu1C1xaS891KqwEUg9NT/Op03iiZqTWeVd0/WJ4H95q2pe9QEDJlwb/FPw==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=20"
+            },
+            "peerDependencies": {
+                "pusher-js": "*",
+                "socket.io-client": "*"
+            }
+        },
         "node_modules/laravel-vite-plugin": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
@@ -2396,6 +2546,14 @@
                 "node": ">= 0.6"
             }
         },
+        "node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true
+        },
         "node_modules/nanoid": {
             "version": "3.3.11",
             "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2497,6 +2655,16 @@
             "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
             "license": "MIT"
         },
+        "node_modules/pusher-js": {
+            "version": "8.4.0",
+            "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
+            "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "tweetnacl": "^1.0.3"
+            }
+        },
         "node_modules/qs": {
             "version": "6.14.0",
             "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -2728,6 +2896,38 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/socket.io-client": {
+            "version": "4.8.1",
+            "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
+            "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "@socket.io/component-emitter": "~3.1.0",
+                "debug": "~4.3.2",
+                "engine.io-client": "~6.6.1",
+                "socket.io-parser": "~4.2.4"
+            },
+            "engines": {
+                "node": ">=10.0.0"
+            }
+        },
+        "node_modules/socket.io-parser": {
+            "version": "4.2.4",
+            "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+            "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "@socket.io/component-emitter": "~3.1.0",
+                "debug": "~4.3.1"
+            },
+            "engines": {
+                "node": ">=10.0.0"
+            }
+        },
         "node_modules/source-map-js": {
             "version": "1.2.1",
             "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2852,6 +3052,13 @@
                 "url": "https://github.com/sponsors/Wombosvideo"
             }
         },
+        "node_modules/tweetnacl": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+            "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
+            "dev": true,
+            "license": "Unlicense"
+        },
         "node_modules/typescript": {
             "version": "5.9.3",
             "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3007,6 +3214,39 @@
                 "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
             }
         },
+        "node_modules/ws": {
+            "version": "8.17.1",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+            "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true,
+            "engines": {
+                "node": ">=10.0.0"
+            },
+            "peerDependencies": {
+                "bufferutil": "^4.0.1",
+                "utf-8-validate": ">=5.0.2"
+            },
+            "peerDependenciesMeta": {
+                "bufferutil": {
+                    "optional": true
+                },
+                "utf-8-validate": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/xmlhttprequest-ssl": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+            "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
+            "dev": true,
+            "peer": true,
+            "engines": {
+                "node": ">=0.4.0"
+            }
+        },
         "node_modules/y18n": {
             "version": "5.0.8",
             "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

+ 4 - 0
package.json

@@ -6,8 +6,11 @@
         "dev": "vite"
     },
     "devDependencies": {
+        "@laravel/echo-vue": "^2.2.6",
         "@tailwindcss/vite": "^4.0.0",
         "concurrently": "^9.0.1",
+        "laravel-echo": "^2.2.6",
+        "pusher-js": "^8.4.0",
         "typescript": "^5.9.3",
         "vite": "^7.2.2"
     },
@@ -16,6 +19,7 @@
         "@tailwindcss/typography": "github:tailwindcss/typography",
         "@vitejs/plugin-vue": "^6.0.1",
         "@vueuse/core": "^14.0.0",
+        "buffer": "^6.0.3",
         "chokidar": "^4.0.3",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",

+ 6 - 0
resources/css/app.css

@@ -33,6 +33,8 @@
     --accent-foreground: oklch(0.21 0.006 285.885);
     --destructive: oklch(0.577 0.245 27.325);
     --destructive-foreground: oklch(1.0000 0 0);
+    --positive: oklch(0.696 0.17 162.48);
+    --positive-foreground: oklch(1.0000 0 0);
     --border: oklch(0.92 0.004 286.32);
     --input: oklch(0.92 0.004 286.32);
     --ring: oklch(0.645 0.246 16.439);
@@ -79,6 +81,8 @@
     --muted-foreground: oklch(0.705 0.015 286.067);
     --accent: oklch(0.274 0.006 286.033);
     --accent-foreground: oklch(0.985 0 0);
+    --positive: oklch(0.696 0.17 162.48);
+    --positive-foreground: oklch(1.0000 0 0);
     --destructive: oklch(0.704 0.191 22.216);
     --destructive-foreground: oklch(1.0000 0 0);
     --border: oklch(1 0 0 / 10%);
@@ -130,6 +134,8 @@
     --color-muted-foreground: var(--muted-foreground);
     --color-accent: var(--accent);
     --color-accent-foreground: var(--accent-foreground);
+    --color-positive: var(--positive);
+    --color-positive-foreground: var(--positive-foreground);
     --color-destructive: var(--destructive);
     --color-destructive-foreground: var(--destructive-foreground);
     --color-border: var(--border);

+ 28 - 0
resources/js/Components/AnimatedDots.vue

@@ -0,0 +1,28 @@
+<script setup>
+defineProps({
+    animating: {
+        type: Boolean,
+        default: true
+    }
+})
+
+</script>
+
+<template>
+    <span :class="{ dots: animating }"></span>
+</template>
+
+<style scoped>
+.dots::after {
+    content: '';
+    animation: dots 1.5s steps(4, end) infinite;
+}
+
+@keyframes dots {
+    0% { content: ''; }
+    25% { content: '.'; }
+    50% { content: '..'; }
+    75% { content: '...'; }
+    100% { content: ''; }
+}
+</style>

+ 36 - 24
resources/js/Components/AppSidebar.vue

@@ -1,43 +1,51 @@
-<script setup lang="ts">
-import {Plus, MoreHorizontal, Trash, MessageSquareOff} from "lucide-vue-next"
+<script lang="ts" setup>
+import {MessageSquareOff, MoreHorizontal, Plus, Trash} from "lucide-vue-next"
 
 import {
     Sidebar,
     SidebarContent,
     SidebarGroup,
+    SidebarGroupAction,
     SidebarGroupContent,
     SidebarGroupLabel,
     SidebarMenu,
+    SidebarMenuAction,
     SidebarMenuButton,
-    SidebarMenuItem,
-    SidebarGroupAction, SidebarMenuAction
+    SidebarMenuItem
 } from '@/Packages/Shadcn/Components/ui/sidebar'
 
 import {
     DropdownMenu,
-    DropdownMenuTrigger,
     DropdownMenuContent,
-    DropdownMenuItem
+    DropdownMenuItem,
+    DropdownMenuTrigger
 } from "@/Packages/Shadcn/Components/ui/dropdown-menu";
 
-import {
-    Empty,
-    EmptyDescription,
-    EmptyHeader,
-    EmptyMedia,
-    EmptyTitle,
-} from '@/Packages/Shadcn/Components/ui/empty'
-import {router} from "@inertiajs/vue3";
+import {Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle,} from '@/Packages/Shadcn/Components/ui/empty'
+
+import {Skeleton} from "@/Packages/Shadcn/Components/ui/skeleton"
+
+import {router, usePage} from "@inertiajs/vue3";
+import {useEcho} from "@laravel/echo-vue";
+import {computed} from "vue";
 
+const page = usePage()
+
+const {user} = defineProps({
+    user: {
+        type: Object,
+        default: () => null,
+    },
 
-defineProps({
     chats: {
         type: Array,
         default: () => [],
-    }
+    },
 })
 
-const items = [{title: "Нет чатов", url: "#"}];
+const chat = computed(() => page.props.chat ?? null)
+
+useEcho(`App.Models.User.${user.id}`, '.ChatUpdated', (e) => router.reload())
 </script>
 
 <template>
@@ -45,7 +53,8 @@ const items = [{title: "Нет чатов", url: "#"}];
         <SidebarContent>
             <SidebarGroup>
                 <SidebarGroupLabel>Application</SidebarGroupLabel>
-                <SidebarGroupAction title="New Chat" class="rounded-full cursor-pointer" @click="() => router.visit(route('chats.index'))">
+                <SidebarGroupAction class="rounded-full cursor-pointer" title="New Chat"
+                                    @click="() => router.visit(route('chats.index'))">
                     <Plus/>
                     <span class="sr-only">New Chat</span>
                 </SidebarGroupAction>
@@ -63,9 +72,12 @@ const items = [{title: "Нет чатов", url: "#"}];
                     </template>
                     <template v-else>
                         <SidebarMenu>
-                            <SidebarMenuItem v-for="item in chats" :key="item.title">
-                                <SidebarMenuButton asChild class="cursor-pointer" @click="() => router.visit(route('chats.view', item.id))">
-                                    <span>{{ item.title ?? 'Новый чат' }}</span>
+                            <SidebarMenuItem v-for="item in chats" :key="item.id">
+                                <SidebarMenuButton :variant="item.id == chat?.id ? 'outline' : 'default'"
+                                                   class="cursor-pointer"
+                                                   @click="() => router.visit(route('chats.show', item.id))">
+                                    <Skeleton class="h-4 w-full rounded-full" v-if="!item.title"/>
+                                    <div v-else class="text-nowrap overflow-hidden text-ellipsis" :title="item.title">{{ item.title }}</div>
                                 </SidebarMenuButton>
                                 <DropdownMenu>
                                     <DropdownMenuTrigger asChild>
@@ -73,8 +85,9 @@ const items = [{title: "Нет чатов", url: "#"}];
                                             <MoreHorizontal/>
                                         </SidebarMenuAction>
                                     </DropdownMenuTrigger>
-                                    <DropdownMenuContent side="right" align="center">
-                                        <DropdownMenuItem class="text-primary cursor-pointer" @click="() => router.delete(route('chats.destroy', item.id), { preserveScroll: false, preserveState: false })">
+                                    <DropdownMenuContent align="center" side="right">
+                                        <DropdownMenuItem class="text-primary cursor-pointer"
+                                                          @click="() => router.delete(route('chats.destroy', item.id), { preserveScroll: false, preserveState: false })">
                                             <Trash/>
                                             <span>Удалить чат</span>
                                         </DropdownMenuItem>
@@ -83,7 +96,6 @@ const items = [{title: "Нет чатов", url: "#"}];
                             </SidebarMenuItem>
                         </SidebarMenu>
                     </template>
-
                 </SidebarGroupContent>
             </SidebarGroup>
         </SidebarContent>

+ 69 - 0
resources/js/Components/ChatInput.vue

@@ -0,0 +1,69 @@
+<script setup lang="ts">
+import {ArrowUpIcon, Loader2} from "lucide-vue-next"
+import {
+    InputGroup,
+    InputGroupAddon,
+    InputGroupButton,
+    InputGroupTextarea
+} from "@/Packages/Shadcn/Components/ui/input-group"
+import {computed, nextTick} from "vue";
+
+const emit = defineEmits(['submit'])
+
+const { disabled } = defineProps({
+    loading: {
+        type: Boolean,
+        default: false
+    },
+
+    disabled: {
+        type: Boolean,
+        default: false
+    },
+})
+
+const value = defineModel({required: true, default: String})
+
+const isEmpty = computed(() => String(value.value ?? '').trim().length === 0)
+
+const submit = () => {
+    if (disabled || isEmpty.value) return
+
+    emit('submit', value.value)
+}
+
+const handleEnter = e => {
+    if (e.metaKey || e.ctrlKey || e.shiftKey) {
+        const {selectionStart, selectionEnd, value} = e.target
+        value.value = value.slice(0, selectionStart) + '\n' + value.slice(selectionEnd)
+        nextTick(() => {
+            const pos = selectionStart + 1
+            e.target.setSelectionRange(pos, pos)
+        })
+    } else submit()
+}
+</script>
+
+<template>
+    <InputGroup class="!bg-card">
+        <InputGroupTextarea placeholder="Ask, Search or Chat..." v-model="value"
+                            @keydown.enter.prevent="handleEnter"/>
+        <InputGroupAddon align="block-end">
+            <InputGroupButton class="ml-auto cursor-pointer" variant="default" :disabled="disabled || isEmpty"
+                              @click="submit">
+                <template v-if="!loading">
+                    <ArrowUpIcon class="size-5"/>
+                    <span>Отправить</span>
+                </template>
+                <template v-else>
+                    <Loader2 class="w-4 h-4 animate-spin"/>
+                    <span>Отправка</span>
+                </template>
+            </InputGroupButton>
+        </InputGroupAddon>
+    </InputGroup>
+</template>
+
+<style scoped>
+
+</style>

+ 72 - 0
resources/js/Components/Message.vue

@@ -0,0 +1,72 @@
+<script setup>
+import {useEchoModel} from "@laravel/echo-vue";
+import {useInferenceResponse} from "@/Composables/useInferenceResponse.js";
+import {Spinner} from "@/Packages/Shadcn/Components/ui/spinner/index.js";
+import AnimatedDots from "@/Components/AnimatedDots.vue";
+import {computed, ref} from "vue";
+import {parse} from "marked";
+import {Badge} from "@/Packages/Shadcn/Components/ui/badge/index.js";
+
+const response = useInferenceResponse()
+
+const {message} = defineProps({
+    message: {
+        type: Object,
+        required: true,
+    }
+})
+
+response.setStatus(message.status)
+
+useEchoModel('App.Models.Message', message.id, '.Wisper', e => response.setStatus(e.status))
+useEchoModel('App.Models.Message', message.id, '.Streaming', e => response.append(e.chunk, e.index))
+
+const stream = computed(() => ({
+    think: response.think.value ? parse(response.think.value) : null,
+    content: response.content.value ? parse(response.content.value) : null,
+    status: response.status.value,
+}))
+
+const thinking = computed(() => message.thinking ? parse(message.thinking) : null)
+const content = computed(() => message.content ? parse(message.content) : null)
+
+const spoiler = ref(false)
+</script>
+
+<template>
+    <div>
+        <div>
+            <div v-if="stream.status === 0">
+                <Badge class="text-sm" variant="outline">
+                    <Spinner class="!text-lg mr-1"/>
+                    <span>Загрузка<AnimatedDots/></span>
+                </Badge>
+            </div>
+            <div v-if="[1, 2].includes(stream.status) || stream.think || thinking">
+                <Badge class="cursor-pointer text-sm" variant="outline" @click="spoiler = !spoiler">
+                    <Spinner v-if="stream.status === 1" class="mr-1"/>
+                    <span>Размышления<AnimatedDots :animating="stream.status === 2"/></span>
+                </Badge>
+            </div>
+        </div>
+
+        <transition
+            enter-active-class="transition-transform duration-300 ease-out"
+            enter-from-class="scale-y-0"
+            enter-to-class="scale-y-100"
+            leave-active-class="transition-transform duration-300 ease-in"
+            leave-from-class="scale-y-100"
+            leave-to-class="scale-y-0"
+        >
+            <div v-show="spoiler"
+                 class="h-[250px] origin-top transform overflow-auto bg-muted rounded-lg px-3 mt-2 border shadow-xs">
+                <div class="max-w-full prose dark:prose-invert text-xs" v-html="thinking ?? stream.think"></div>
+            </div>
+        </transition>
+        <div class="max-w-full prose dark:prose-invert text-sm mt-2" v-html="content ?? stream.content"></div>
+    </div>
+</template>
+
+<style scoped>
+
+</style>

+ 83 - 0
resources/js/Composables/useInferenceResponse.js

@@ -0,0 +1,83 @@
+import {ref} from 'vue'
+
+class ChunkParser {
+    constructor(tags, name = 'inference_rag_') {
+        this.name = name
+        this.tags = tags
+        this.chunks = []
+    }
+
+    append(chunk, index) {
+        this.chunks.push({index: index, chunk: chunk})
+    }
+
+    parse(handlers) {
+        const buffer = this.buffer()
+
+        this.tags.forEach(tag => {
+            const positions = this.position(buffer, tag)
+            let string = null
+
+            if(positions.length > 0) {
+                const length = `<|${this.name}${tag}|>`.length
+                string = buffer.substring(positions[0] + length, positions[1] || buffer.length)
+            }
+
+            handlers[tag]?.(string)
+        })
+    }
+
+    position(input, tag) {
+        const regex = new RegExp(`<\\|${this.name}${tag}\\|>`, 'g')
+        return [...input.matchAll(regex)].map(match => match.index)
+    }
+
+    buffer() {
+        if (!this.chunks.length) return '';
+
+        const [{index: firstIndex}] = this.chunks.slice().sort((a, b) => a.index - b.index);
+
+        return this.chunks
+            .slice()
+            .sort((a, b) => a.index - b.index)
+            .filter(({index}, i) => index === firstIndex + i)
+            .map(({chunk}) => chunk)
+            .join('');
+    }
+
+    reset() {
+        this.chunks = []
+    }
+}
+
+export function useInferenceResponse() {
+    const status = ref(null)
+    const think = ref(null)
+    const content = ref(null)
+
+    const parser = new ChunkParser(['think', 'content', 'fields'])
+
+    function append(chunk, index) {
+        parser.append(chunk, index)
+        parser.parse({
+            think: text => think.value = text,
+            content: text => content.value = text,
+            fields: () => {
+            },
+        })
+    }
+
+    function setStatus(value) {
+        status.value = value
+    }
+
+    function reset() {
+        think.value = null
+        content.value = null
+        status.value = null
+
+        parser.reset()
+    }
+
+    return {think, content, status, append, reset, setStatus}
+}

+ 12 - 10
resources/js/Layouts/AppLayout.vue

@@ -1,30 +1,32 @@
 <script setup lang="ts">
 import AppSidebar from '@/Components/AppSidebar.vue'
-import {SidebarProvider, SidebarTrigger} from '@/Packages/Shadcn/Components/ui/sidebar'
-import {Head} from "@inertiajs/vue3";
+import {SidebarProvider} from '@/Packages/Shadcn/Components/ui/sidebar'
+import {Head, usePage} from "@inertiajs/vue3";
+import {computed} from "vue";
 
-defineOptions({ inheritAttrs: false })
+const page = usePage()
+
+defineOptions({inheritAttrs: false})
 
 defineProps({
     pageClass: {
         default: () => null,
-    },
-
-    chats: {
-        type: Array,
-        default: () => [],
     }
 })
 
+const user = computed(() => page?.props?.auth?.user)
+const chats = computed(() => page?.props?.auth?.chats ?? [])
+
 </script>
 
 <template>
     <Head/>
 
     <SidebarProvider>
-        <AppSidebar :chats="chats" />
+        <AppSidebar v-bind="{ user, chats }"/>
 
-        <main class="container mx-auto px-2 md:px-0 md:pr-2 py-2" :class="pageClass">
+        <main class="w-full px-2 md:px-0 md:pr-2 py-2" :class="pageClass">
+<!--            <div class="sticky top-2 bg-background px-3 py-2">Steam Deck QA</div>-->
             <slot/>
         </main>
     </SidebarProvider>

+ 21 - 0
resources/js/Packages/Shadcn/Components/ui/alert/Alert.vue

@@ -0,0 +1,21 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import type { AlertVariants } from "."
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+import { alertVariants } from "."
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+  variant?: AlertVariants["variant"]
+}>()
+</script>
+
+<template>
+  <div
+    data-slot="alert"
+    :class="cn(alertVariants({ variant }), props.class)"
+    role="alert"
+  >
+    <slot />
+  </div>
+</template>

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/alert/AlertDescription.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <div
+    data-slot="alert-description"
+    :class="cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)"
+  >
+    <slot />
+  </div>
+</template>

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/alert/AlertTitle.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <div
+    data-slot="alert-title"
+    :class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
+  >
+    <slot />
+  </div>
+</template>

+ 26 - 0
resources/js/Packages/Shadcn/Components/ui/alert/index.ts

@@ -0,0 +1,26 @@
+import type { VariantProps } from "class-variance-authority"
+import { cva } from "class-variance-authority"
+
+export { default as Alert } from "./Alert.vue"
+export { default as AlertDescription } from "./AlertDescription.vue"
+export { default as AlertTitle } from "./AlertTitle.vue"
+
+export const alertVariants = cva(
+  "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+  {
+    variants: {
+      variant: {
+        default: "bg-card text-card-foreground",
+        positive:
+          "text-positive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-positive/90",
+        destructive:
+          "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+)
+
+export type AlertVariants = VariantProps<typeof alertVariants>

+ 26 - 0
resources/js/Packages/Shadcn/Components/ui/badge/Badge.vue

@@ -0,0 +1,26 @@
+<script setup lang="ts">
+import type { PrimitiveProps } from "reka-ui"
+import type { HTMLAttributes } from "vue"
+import type { BadgeVariants } from "."
+import { reactiveOmit } from "@vueuse/core"
+import { Primitive } from "reka-ui"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+import { badgeVariants } from "."
+
+const props = defineProps<PrimitiveProps & {
+  variant?: BadgeVariants["variant"]
+  class?: HTMLAttributes["class"]
+}>()
+
+const delegatedProps = reactiveOmit(props, "class")
+</script>
+
+<template>
+  <Primitive
+    data-slot="badge"
+    :class="cn(badgeVariants({ variant }), props.class)"
+    v-bind="delegatedProps"
+  >
+    <slot />
+  </Primitive>
+</template>

+ 26 - 0
resources/js/Packages/Shadcn/Components/ui/badge/index.ts

@@ -0,0 +1,26 @@
+import type { VariantProps } from "class-variance-authority"
+import { cva } from "class-variance-authority"
+
+export { default as Badge } from "./Badge.vue"
+
+export const badgeVariants = cva(
+  "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+  {
+    variants: {
+      variant: {
+        default:
+          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+        secondary:
+          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+        destructive:
+         "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+)
+export type BadgeVariants = VariantProps<typeof badgeVariants>

+ 22 - 0
resources/js/Packages/Shadcn/Components/ui/card/Card.vue

@@ -0,0 +1,22 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <div
+    data-slot="card"
+    :class="
+      cn(
+        'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
+        props.class,
+      )
+    "
+  >
+    <slot />
+  </div>
+</template>

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/card/CardAction.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <div
+    data-slot="card-action"
+    :class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
+  >
+    <slot />
+  </div>
+</template>

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/card/CardContent.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <div
+    data-slot="card-content"
+    :class="cn('px-6', props.class)"
+  >
+    <slot />
+  </div>
+</template>

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/card/CardDescription.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <p
+    data-slot="card-description"
+    :class="cn('text-muted-foreground text-sm', props.class)"
+  >
+    <slot />
+  </p>
+</template>

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/card/CardFooter.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <div
+    data-slot="card-footer"
+    :class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
+  >
+    <slot />
+  </div>
+</template>

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/card/CardHeader.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <div
+    data-slot="card-header"
+    :class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
+  >
+    <slot />
+  </div>
+</template>

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/card/CardTitle.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <h3
+    data-slot="card-title"
+    :class="cn('leading-none font-semibold', props.class)"
+  >
+    <slot />
+  </h3>
+</template>

+ 7 - 0
resources/js/Packages/Shadcn/Components/ui/card/index.ts

@@ -0,0 +1,7 @@
+export { default as Card } from "./Card.vue"
+export { default as CardAction } from "./CardAction.vue"
+export { default as CardContent } from "./CardContent.vue"
+export { default as CardDescription } from "./CardDescription.vue"
+export { default as CardFooter } from "./CardFooter.vue"
+export { default as CardHeader } from "./CardHeader.vue"
+export { default as CardTitle } from "./CardTitle.vue"

+ 26 - 0
resources/js/Packages/Shadcn/Components/ui/label/Label.vue

@@ -0,0 +1,26 @@
+<script setup lang="ts">
+import type { LabelProps } from "reka-ui"
+import type { HTMLAttributes } from "vue"
+import { reactiveOmit } from "@vueuse/core"
+import { Label } from "reka-ui"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
+
+const delegatedProps = reactiveOmit(props, "class")
+</script>
+
+<template>
+  <Label
+    data-slot="label"
+    v-bind="delegatedProps"
+    :class="
+      cn(
+        'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
+        props.class,
+      )
+    "
+  >
+    <slot />
+  </Label>
+</template>

+ 1 - 0
resources/js/Packages/Shadcn/Components/ui/label/index.ts

@@ -0,0 +1 @@
+export { default as Label } from "./Label.vue"

+ 17 - 0
resources/js/Packages/Shadcn/Components/ui/spinner/Spinner.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import type { HTMLAttributes } from "vue"
+import { Loader2Icon } from "lucide-vue-next"
+import { cn } from '@/Packages/Shadcn/Lib/utils'
+
+const props = defineProps<{
+  class?: HTMLAttributes["class"]
+}>()
+</script>
+
+<template>
+  <Loader2Icon
+    role="status"
+    aria-label="Loading"
+    :class="cn('size-4 animate-spin', props.class)"
+  />
+</template>

+ 1 - 0
resources/js/Packages/Shadcn/Components/ui/spinner/index.ts

@@ -0,0 +1 @@
+export { default as Spinner } from "./Spinner.vue"

+ 81 - 0
resources/js/Pages/Auth/Login.vue

@@ -0,0 +1,81 @@
+<script setup lang="ts">
+import type {HTMLAttributes} from "vue"
+import {Rocket, CircleCheckBig, ArrowUpIcon, Loader2} from "lucide-vue-next"
+import {cn} from '@/Packages/Shadcn/Lib/utils'
+import {Button} from '@/Packages/Shadcn/Components/ui/button'
+import {
+    Card,
+    CardContent,
+    CardDescription,
+    CardHeader,
+    CardTitle,
+} from '@/Packages/Shadcn/Components/ui/card'
+import {Input} from '@/Packages/Shadcn/Components/ui/input'
+import {Label} from '@/Packages/Shadcn/Components/ui/label'
+import {Alert, AlertDescription} from "@/Packages/Shadcn/Components/ui/alert"
+import {Form} from '@inertiajs/vue3'
+
+const props = defineProps<{
+    class?: HTMLAttributes["class"],
+    status?: String
+}>()
+</script>
+
+<template>
+    <div class="flex h-screen w-full items-center justify-center px-4">
+        <div class="min-w-[400px]" :class="cn('flex flex-col gap-6', props.class)">
+
+            <Alert variant="positive" v-if="status">
+                <CircleCheckBig class="h-4 w-4"/>
+                <AlertDescription>{{ status }}</AlertDescription>
+            </Alert>
+
+            <Card>
+                <CardHeader>
+                    <CardTitle>Вход в аккаунт</CardTitle>
+                    <CardDescription>Введите Ваш Email, чтобы войти в аккаунт</CardDescription>
+                </CardHeader>
+                <CardContent>
+                    <Form :action="route('login')" method="post" :show-progress="false" disable-while-processing
+                          :resetOnError="['password']" #default="{ errors, processing, isDirty }">
+                        <div class="flex flex-col gap-6">
+                            <div class="grid gap-3">
+                                <Label for="email" class="cursor-pointer">Email</Label>
+                                <Input
+                                    id="email"
+                                    name="email"
+                                    type="email"
+                                    placeholder="email@example.com"
+                                    required
+                                />
+                                <div class="text-destructive/90 text-sm" v-if="errors.email">
+                                    {{ errors.email }}
+                                </div>
+                            </div>
+                            <div class="grid gap-3">
+                                <div class="flex items-center">
+                                    <Label for="password" class="cursor-pointer">Пароль</Label>
+                                </div>
+                                <Input id="password" type="password" name="password" required/>
+                                <div class="text-destructive/90 text-sm" v-if="errors.password">
+                                    {{ errors.password }}
+                                </div>
+                            </div>
+                            <div class="flex flex-col gap-3">
+                                <Button type="submit" class="w-full cursor-pointer" :disabled="!isDirty || processing">
+                                    <template v-if="!processing">
+                                        <span>Войти</span>
+                                    </template>
+                                    <template v-else>
+                                        <Loader2 class="w-4 h-4 animate-spin"/>
+                                        Авторизация
+                                    </template>
+                                </Button>
+                            </div>
+                        </div>
+                    </Form>
+                </CardContent>
+            </Card>
+        </div>
+    </div>
+</template>

+ 0 - 83
resources/js/Pages/Chat.vue

@@ -1,83 +0,0 @@
-<script setup lang="ts">
-import AppLayout from "@/Layouts/AppLayout.vue"
-import {ArrowUpIcon, Loader2} from "lucide-vue-next"
-import {
-    InputGroup,
-    InputGroupAddon,
-    InputGroupButton,
-    InputGroupTextarea
-} from "@/Packages/Shadcn/Components/ui/input-group"
-import {computed, nextTick, watch} from "vue";
-import {useForm} from "@inertiajs/vue3";
-import { parse } from 'marked'
-
-const { chat } = defineProps(['chats', 'chat'])
-const form = useForm({
-    uuid: chat?.id ?? null,
-    message: null
-})
-
-const submit = function () {
-    if (!form.message || form.processing) return
-    form.post(route('chats.message'), {
-        onSuccess: () => form.reset()
-    })
-}
-
-const handleEnter = function (e) {
-    if (e.metaKey || e.ctrlKey || e.shiftKey) {
-        const {selectionStart, selectionEnd, value} = e.target
-        form.message = value.slice(0, selectionStart) + '\n' + value.slice(selectionEnd)
-        nextTick(() => {
-            const pos = selectionStart + 1
-            e.target.setSelectionRange(pos, pos)
-        })
-    } else submit()
-}
-
-const messages = computed(() => chat?.messages ?? [])
-
-watch(() => chat?.id, () => form.defaults('uuid', chat?.id ?? null))
-</script>
-
-<template>
-    <AppLayout page-class="!py-0" :chats="chats">
-        <div class="max-w-4xl mx-auto flex flex-col h-full">
-            <div class="overflow-y-auto duration-500 space-y-4" :class="{'grow mt-2': messages.length !== 0}">
-                <div v-for="message in messages">
-                    <div class="flex" v-if="message.from === 'user'">
-                        <div class="ml-auto border bg-card text-card-foreground p-2 rounded-lg max-w-[75%]"
-                             v-html="message.text.replaceAll('\n', '<br />')"></div>
-                    </div>
-                    <div class="prose dark:prose-invert" v-else v-html="parse(message.text)"></div>
-                </div>
-                <!--                <div class=""></div>-->
-            </div>
-
-            <div class="my-auto pb-2 pt-5" :class="{'sticky bottom-0': messages.length !== 0}">
-                <InputGroup class="!bg-card">
-                    <InputGroupTextarea placeholder="Ask, Search or Chat..." v-model="form.message"
-                                        @keydown.enter.prevent="handleEnter"/>
-                    <InputGroupAddon align="block-end">
-                        <InputGroupButton class="ml-auto cursor-pointer" variant="default"
-                                          :disabled="!form.message || form.processing" :loading="true"
-                                          @click="() => submit()">
-                            <template v-if="!form.processing">
-                                <ArrowUpIcon class="size-5"/>
-                                <span>Отправить</span>
-                            </template>
-                            <template v-else>
-                                <Loader2 class="w-4 h-4 animate-spin"/>
-                                Отправка
-                            </template>
-                        </InputGroupButton>
-                    </InputGroupAddon>
-                </InputGroup>
-            </div>
-        </div>
-    </AppLayout>
-</template>
-
-<style scoped>
-
-</style>

+ 22 - 0
resources/js/Pages/Chat/Create.vue

@@ -0,0 +1,22 @@
+<script setup lang="ts">
+import AppLayout from "@/Layouts/AppLayout.vue";
+import ChatInput from "@/Components/ChatInput.vue";
+import {useForm} from "@inertiajs/vue3";
+
+const form = useForm({
+    message: null
+})
+
+const submit = () => {
+    form.post(route('chats.store'), {
+        onSuccess: () => form.reset()
+    })
+}
+
+</script>
+
+<template>
+    <AppLayout page-class="mx-auto max-w-4xl flex items-center justify-center">
+        <ChatInput v-model="form.message" :disabled="form.processing" @submit="submit"/>
+    </AppLayout>
+</template>

+ 68 - 0
resources/js/Pages/Chat/View.vue

@@ -0,0 +1,68 @@
+<script setup>
+import AppLayout from "@/Layouts/AppLayout.vue";
+import {router, useForm} from "@inertiajs/vue3";
+import ChatInput from "@/Components/ChatInput.vue";
+import {useEcho} from "@laravel/echo-vue";
+import Message from "@/Components/Message.vue";
+import {nextTick, onMounted} from "vue";
+
+const {chat} = defineProps({chat: {type: Object}})
+const form = useForm({message: null})
+
+const submit = () => {
+    form.post(route('chats.messages.store', chat.id), {
+        preserveScroll: true,
+        onSuccess: () => {
+            scroll()
+            form.reset()
+        }
+    })
+}
+
+useEcho(`App.Models.Chat.${chat.id}`, '.MessageCreated', (e) => {
+    if (e?.data?.from !== 'user') {
+        reload()
+    }
+});
+
+useEcho(`App.Models.Chat.${chat.id}`, '.MessageUpdated', () => reload());
+
+const reload = () => {
+    router.reload({preserveScroll: true, only: ['chat'], onSuccess: () => scroll()})
+}
+
+const scroll = () => {
+    window.scrollBy({
+        top: document.body.scrollHeight,
+        behavior: 'smooth'
+    });
+}
+
+onMounted(() => nextTick(() => scroll()))
+</script>
+
+<template>
+    <AppLayout page-class="">
+        <div class="max-w-5xl mx-auto flex flex-col h-full">
+            <div class="flex-1 my-2">
+                <div class="space-y-4">
+                    <div v-for="message in chat.messages">
+                        <div v-if="message.from === 'user'" class="flex">
+                            <div class="ml-auto border bg-card text-card-foreground p-2 rounded-lg max-w-[75%] text-sm"
+                                 v-html="message.content.replaceAll('\n', '<br />')"></div>
+                        </div>
+                        <Message v-else :message="message"/>
+                    </div>
+                </div>
+            </div>
+            <div class="shrink-0 sticky bottom-2">
+                <!--                // TODO: If Last Message Not In Canceled Failed or Completed -->
+                <ChatInput v-model="form.message" :disabled="form.processing" @submit="submit"/>
+            </div>
+        </div>
+    </AppLayout>
+</template>
+
+<style scoped>
+
+</style>

+ 18 - 0
resources/js/bootstrap.js

@@ -2,3 +2,21 @@ import axios from 'axios';
 window.axios = axios;
 
 window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+
+/**
+ * Echo exposes an expressive API for subscribing to channels and listening
+ * for events that are broadcast by Laravel. Echo and event broadcasting
+ * allow your team to quickly build robust real-time web applications.
+ */
+import { configureEcho } from "@laravel/echo-vue";
+
+configureEcho({
+    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'],
+
+});

+ 17 - 0
routes/auth.php

@@ -0,0 +1,17 @@
+<?php
+
+use App\Http\Controllers\Auth\AuthenticatedSessionController;
+use Illuminate\Support\Facades\Route;
+
+Route::middleware('guest')->group(function () {
+    Route::controller(AuthenticatedSessionController::class)->group(function () {
+        Route::get('/login', 'create')->name('login');
+        Route::post('/login', 'store');
+    });
+});
+
+Route::middleware('auth')->group(function () {
+    Route::controller(AuthenticatedSessionController::class)->group(function () {
+        Route::post('/logout', 'destroy')->name('logout');
+    });
+});

+ 11 - 0
routes/channels.php

@@ -0,0 +1,11 @@
+<?php
+
+use App\Models\Chat;
+use App\Models\Message;
+use App\Models\User;
+use Illuminate\Support\Facades\Broadcast;
+
+Broadcast::channel('App.Models.User.{id}', fn (User $user, string $id) => $user->id == $id);
+
+Broadcast::channel('App.Models.Chat.{uuid}', fn (User $user, string $uuid) => $user->id === Chat::findOrFail($uuid)->user_id);
+Broadcast::channel('App.Models.Message.{uuid}', fn (User $user, string $uuid) => $user->id == Message::findOrFail($uuid)->chat->user_id);

+ 18 - 7
routes/web.php

@@ -1,17 +1,28 @@
 <?php
 
 use App\Http\Controllers\ChatController;
+use App\Http\Controllers\MessageController;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Route;
 
-Route::get('/', fn () => redirect()->route('chats.index'));
+Route::middleware('auth:sanctum')->group(function () {
+    Route::get('/', fn() => redirect()->route('chats.index'))->name('index');
 
-Route::prefix('chats')->name('chats.')->group(function () {
-    Route::get('/', [ChatController::class, 'index'])->name('index');
-    Route::get('/{chat}', [ChatController::class, 'index'])->name('view');
-    Route::delete('/{chat}', [ChatController::class, 'destroy'])->name('destroy');
-    Route::post('/message', [ChatController::class, 'message'])->name('message');
-});
 
+    Route::prefix('chats')->name('chats.')->group(function () {
+        Route::get('/', fn() => redirect()->route('chats.create'))->name('index');
+        Route::get('/create', [ChatController::class, 'create'])->name('create');
+        Route::post('/', [ChatController::class, 'store'])->name('store');
+        Route::get('/{chat}', [ChatController::class, 'show'])->name('show');
+        Route::delete('/{chat}', [ChatController::class, 'destroy'])->name('destroy');
+
+        Route::prefix('{chat}/messages')->name('messages.')->group(function () {
+            Route::post('/', [MessageController::class, 'store'])->name('store');
+        });
+    });
+
+});
 
 Route::get('/metrics', static fn() => Http::get('http://127.0.0.1:9019/metrics'));
+
+require __DIR__ . '/auth.php';