diff --git a/CLAUDE.md b/CLAUDE.md index 49b1cd1..2bfd00c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Project Overview + +Clueless is an AI-powered meeting assistant that provides real-time transcription, intelligent analysis, and action item extraction from conversations. It's built as a single-user desktop application using Electron/NativePHP with OpenAI's Realtime API for voice conversations. + ## Tech Stack - **Backend**: Laravel 12.0 (PHP 8.2+) @@ -10,7 +14,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Build**: Vite 6 - **Desktop**: NativePHP/Electron - **Testing**: Pest PHP -- **Database**: SQLite (default) +- **Database**: SQLite (dual database setup) +- **Real-time**: OpenAI Realtime API, WebSockets +- **AI Integration**: OpenAI Realtime API only ## Development Commands @@ -69,6 +75,15 @@ php artisan test tests/Feature/DashboardTest.php # Run tests with coverage (if configured) php artisan test --coverage + +# Run tests in parallel +php artisan test --parallel + +# Run only unit tests +php artisan test --testsuite=Unit + +# Run only feature tests +php artisan test --testsuite=Feature ``` ## Project Architecture @@ -76,15 +91,17 @@ php artisan test --coverage ### Directory Structure - `/app/` - Laravel backend logic - - `/Http/Controllers/` - Request handlers (Auth, Settings) - - `/Models/` - Eloquent models + - `/Http/Controllers/` - Request handlers (Conversations, Realtime, Settings) + - `/Models/` - Eloquent models (Conversation, Transcript, etc.) + - `/Services/` - Business logic (ApiKeyService, RealtimeRelayService, TranscriptionService) - `/Providers/` - Service providers - `/resources/js/` - Vue frontend application - `/components/` - Reusable Vue components - - `/components/ui/` - UI component library (shadcn/ui-inspired) + - `/components/ui/` - UI component library (Reka UI based, shadcn/ui-inspired) - `/pages/` - Inertia.js page components - - `/layouts/` - Layout components - - `/composables/` - Vue composables (e.g., useAppearance) + - `/layouts/` - Layout components (App, Settings) + - `/composables/` - Vue composables (useAppearance, useRealtime, etc.) + - `/services/` - Frontend services (audioCaptureService, realtimeClient) - `/types/` - TypeScript type definitions - `/routes/` - Application routing (web.php) - `/database/` - Migrations, factories, seeders @@ -95,8 +112,10 @@ php artisan test --coverage 1. **Inertia.js Integration**: Pages are Vue components loaded via Inertia.js, providing SPA-like experience without API endpoints 2. **Component Library**: UI components in `/resources/js/components/ui/` follow Reka UI patterns 3. **TypeScript**: Strict mode enabled, with path alias `@/` for `/resources/js/` -4. **Authentication**: Built-in Laravel auth with custom Vue components +4. **No Authentication**: Single-user desktop app with no login required 5. **Theme Support**: Dark/light mode via `useAppearance` composable +6. **Real-time Features**: WebSocket connections for live transcription using OpenAI Realtime API +7. **Service Architecture**: Core business logic separated into service classes (ApiKeyService, TranscriptionService, etc.) ### Important Files @@ -137,6 +156,9 @@ php artisan db:seed 2. Generate app key: `php artisan key:generate` 3. Create SQLite database: `touch database/database.sqlite` 4. Run migrations: `php artisan migrate` +5. Configure OpenAI API key either: + - In `.env` file: `OPENAI_API_KEY=sk-...` + - Or via Settings > API Keys after launching the app ## Database Migrations @@ -167,9 +189,34 @@ Example usage: - For Laravel docs: Use Context7 to get the latest Laravel 12 documentation - For any other library: First use `mcp__context7__resolve-library-id` to find the library +## Working with Real-time Features + +The application uses OpenAI's Realtime API for live transcription: +- Frontend audio capture: `/resources/js/services/audioCaptureService.ts` +- WebSocket client: `/resources/js/services/realtimeClient.ts` +- Backend relay service: `/app/Services/RealtimeRelayService.php` +- Controllers: `/app/Http/Controllers/RealtimeController.php` + +## API Key Management + +The application uses a simple API key management system: +- API keys are stored in Laravel's cache (not database) +- Configure via Settings > API Keys in the app +- Falls back to `.env` OPENAI_API_KEY if not configured +- Realtime Agent checks for API key on startup + +## License & Usage + +This project uses MIT License with Commons Clause: +- ✅ Free for personal and internal business use +- ❌ Commercial use (SaaS, resale) requires commercial agreement +- See `COMMONS_CLAUSE.md` for details + ## Notes - ESLint ignores `/resources/js/components/ui/*` (third-party UI components) - Prettier formats all files in `/resources/` directory - NativePHP allows running as desktop application -- Concurrently runs multiple processes in development (server, queue, logs, vite) \ No newline at end of file +- Concurrently runs multiple processes in development (server, queue, logs, vite) +- Built primarily for macOS desktop usage +- Uses Pusher for additional real-time features \ No newline at end of file diff --git a/app/Console/Commands/NativeConfigCommand.php b/app/Console/Commands/NativeConfigCommand.php index 35441a4..c8a90c8 100644 --- a/app/Console/Commands/NativeConfigCommand.php +++ b/app/Console/Commands/NativeConfigCommand.php @@ -27,6 +27,7 @@ public function handle() { // Return empty JSON to satisfy NativePHP expectations echo json_encode([]); + return 0; } -} \ No newline at end of file +} diff --git a/app/Console/Commands/NativePhpIniCommand.php b/app/Console/Commands/NativePhpIniCommand.php index aa14d8a..dd69985 100644 --- a/app/Console/Commands/NativePhpIniCommand.php +++ b/app/Console/Commands/NativePhpIniCommand.php @@ -32,6 +32,7 @@ public function handle() 'post_max_size' => '50M', ]; echo json_encode($settings); + return 0; } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/AssistantController.php b/app/Http/Controllers/AssistantController.php deleted file mode 100644 index 231afec..0000000 --- a/app/Http/Controllers/AssistantController.php +++ /dev/null @@ -1,217 +0,0 @@ -validate([ - 'message' => 'required|string', - ]); - - try { - // Get AI response - $response = $this->aiService->chat( - $request->input('message'), - [] - ); - - return response()->json([ - 'response' => $response['content'] ?? 'I apologize, but I was unable to generate a response.', - ]); - } catch (\Exception $e) { - Log::error('Chat error: ' . $e->getMessage()); - - return response()->json([ - 'error' => 'Failed to get AI response' - ], 500); - } - } - - public function transcribe(Request $request) - { - $request->validate([ - 'audio' => 'required|string', - ]); - - try { - $audioData = $request->input('audio'); - - // Use OpenAI Whisper for transcription - $result = $this->aiService->transcribe($audioData); - - return response()->json([ - 'text' => $result['text'] ?? '', - ]); - } catch (\Exception $e) { - Log::error('Transcription error: ' . $e->getMessage()); - - return response()->json([ - 'error' => 'Failed to transcribe audio' - ], 500); - } - } - - public function analyzeConversation(Request $request) - { - $request->validate([ - 'audio' => 'required|string', - 'context' => 'nullable|string', - 'mode' => 'required|in:sales-coaching', - ]); - - try { - $audioData = $request->input('audio'); - $context = $request->input('context', ''); - - // Ensure context is never null - if (is_null($context)) { - $context = ''; - } - - // First, transcribe the audio - $transcriptionResult = $this->aiService->transcribe($audioData); - $transcript = $transcriptionResult['text'] ?? ''; - - if (empty($transcript)) { - return response()->json([ - 'transcript' => '', - 'suggestions' => [], - 'metrics' => null - ]); - } - - // Analyze the conversation for sales coaching - $analysis = $this->aiService->analyzeSalesConversation($transcript, $context); - - return response()->json([ - 'transcript' => $transcript, - 'suggestions' => $analysis['suggestions'] ?? [], - 'metrics' => $analysis['metrics'] ?? null - ]); - } catch (\Exception $e) { - Log::error('Conversation analysis error: ' . $e->getMessage()); - - return response()->json([ - 'error' => 'Failed to analyze conversation' - ], 500); - } - } - - public function analyzeConversationStream(Request $request) - { - $request->validate([ - 'audio' => 'required|string', - 'context' => 'nullable|string', - 'mode' => 'required|in:sales-coaching', - 'sessionId' => 'nullable|string', - ]); - - return response()->stream(function () use ($request) { - // Set up SSE headers - header('Content-Type: text/event-stream'); - header('Cache-Control: no-cache'); - header('Connection: keep-alive'); - header('X-Accel-Buffering: no'); // Disable Nginx buffering - - // Send initial connection event - echo "data: " . json_encode(['type' => 'connected']) . "\n\n"; - ob_flush(); - flush(); - - try { - $audioData = $request->input('audio'); - $context = $request->input('context', ''); - $sessionId = $request->input('sessionId'); - - // Ensure context is never null - if (is_null($context)) { - $context = ''; - } - - // Send processing event - echo "data: " . json_encode([ - 'type' => 'processing', - 'sessionId' => $sessionId, - 'timestamp' => now()->toIso8601String() - ]) . "\n\n"; - ob_flush(); - flush(); - - // Transcribe the audio chunk - $transcriptionResult = $this->aiService->transcribe($audioData); - $transcript = $transcriptionResult['text'] ?? ''; - - if (!empty($transcript)) { - // Send transcription event - echo "data: " . json_encode([ - 'type' => 'transcription', - 'text' => $transcript, - 'sessionId' => $sessionId, - 'timestamp' => now()->toIso8601String() - ]) . "\n\n"; - ob_flush(); - flush(); - - // Analyze the conversation - $analysis = $this->aiService->analyzeSalesConversation($transcript, $context); - - // Send analysis event - echo "data: " . json_encode([ - 'type' => 'analysis', - 'suggestions' => $analysis['suggestions'] ?? [], - 'metrics' => $analysis['metrics'] ?? null, - 'sessionId' => $sessionId, - 'timestamp' => now()->toIso8601String() - ]) . "\n\n"; - ob_flush(); - flush(); - } else { - // Send empty transcription event - echo "data: " . json_encode([ - 'type' => 'transcription', - 'text' => '', - 'sessionId' => $sessionId, - 'timestamp' => now()->toIso8601String() - ]) . "\n\n"; - ob_flush(); - flush(); - } - - } catch (\Exception $e) { - // Send error event - Log::error('Stream processing error: ' . $e->getMessage()); - echo "data: " . json_encode([ - 'type' => 'error', - 'message' => 'Processing error occurred', - 'error' => $e->getMessage(), - 'timestamp' => now()->toIso8601String() - ]) . "\n\n"; - ob_flush(); - flush(); - } - - // Send completion event - echo "data: " . json_encode([ - 'type' => 'complete', - 'timestamp' => now()->toIso8601String() - ]) . "\n\n"; - ob_flush(); - flush(); - }, 200, [ - 'Content-Type' => 'text/event-stream', - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'X-Accel-Buffering' => 'no', - ]); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php deleted file mode 100644 index dbb9365..0000000 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ /dev/null @@ -1,51 +0,0 @@ - Route::has('password.request'), - 'status' => $request->session()->get('status'), - ]); - } - - /** - * Handle an incoming authentication request. - */ - public function store(LoginRequest $request): RedirectResponse - { - $request->authenticate(); - - $request->session()->regenerate(); - - return redirect()->intended(route('dashboard', absolute: false)); - } - - /** - * Destroy an authenticated session. - */ - public function destroy(Request $request): RedirectResponse - { - Auth::guard('web')->logout(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return redirect('/'); - } -} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php deleted file mode 100644 index fb7d8e0..0000000 --- a/app/Http/Controllers/Auth/ConfirmablePasswordController.php +++ /dev/null @@ -1,41 +0,0 @@ -validate([ - 'email' => $request->user()->email, - 'password' => $request->password, - ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); - } - - $request->session()->put('auth.password_confirmed_at', time()); - - return redirect()->intended(route('dashboard', absolute: false)); - } -} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php deleted file mode 100644 index f64fa9b..0000000 --- a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php +++ /dev/null @@ -1,24 +0,0 @@ -user()->hasVerifiedEmail()) { - return redirect()->intended(route('dashboard', absolute: false)); - } - - $request->user()->sendEmailVerificationNotification(); - - return back()->with('status', 'verification-link-sent'); - } -} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php deleted file mode 100644 index bf57a20..0000000 --- a/app/Http/Controllers/Auth/EmailVerificationPromptController.php +++ /dev/null @@ -1,22 +0,0 @@ -user()->hasVerifiedEmail() - ? redirect()->intended(route('dashboard', absolute: false)) - : Inertia::render('auth/VerifyEmail', ['status' => $request->session()->get('status')]); - } -} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php deleted file mode 100644 index 56ae905..0000000 --- a/app/Http/Controllers/Auth/NewPasswordController.php +++ /dev/null @@ -1,69 +0,0 @@ - $request->email, - 'token' => $request->route('token'), - ]); - } - - /** - * Handle an incoming new password request. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function store(Request $request): RedirectResponse - { - $request->validate([ - 'token' => 'required', - 'email' => 'required|email', - 'password' => ['required', 'confirmed', Rules\Password::defaults()], - ]); - - // Here we will attempt to reset the user's password. If it is successful we - // will update the password on an actual user model and persist it to the - // database. Otherwise we will parse the error and return the response. - $status = Password::reset( - $request->only('email', 'password', 'password_confirmation', 'token'), - function ($user) use ($request) { - $user->forceFill([ - 'password' => Hash::make($request->password), - 'remember_token' => Str::random(60), - ])->save(); - - event(new PasswordReset($user)); - } - ); - - // If the password was successfully reset, we will redirect the user back to - // the application's home authenticated view. If there is an error we can - // redirect them back to where they came from with their error message. - if ($status == Password::PasswordReset) { - return to_route('login')->with('status', __($status)); - } - - throw ValidationException::withMessages([ - 'email' => [__($status)], - ]); - } -} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php deleted file mode 100644 index a2b6e38..0000000 --- a/app/Http/Controllers/Auth/PasswordResetLinkController.php +++ /dev/null @@ -1,41 +0,0 @@ - $request->session()->get('status'), - ]); - } - - /** - * Handle an incoming password reset link request. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function store(Request $request): RedirectResponse - { - $request->validate([ - 'email' => 'required|email', - ]); - - Password::sendResetLink( - $request->only('email') - ); - - return back()->with('status', __('A reset link will be sent if the account exists.')); - } -} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php deleted file mode 100644 index c7138ca..0000000 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ /dev/null @@ -1,51 +0,0 @@ -validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, - 'password' => ['required', 'confirmed', Rules\Password::defaults()], - ]); - - $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), - ]); - - event(new Registered($user)); - - Auth::login($user); - - return to_route('dashboard'); - } -} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php deleted file mode 100644 index 2477faa..0000000 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ /dev/null @@ -1,29 +0,0 @@ -user()->hasVerifiedEmail()) { - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); - } - - if ($request->user()->markEmailAsVerified()) { - /** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */ - $user = $request->user(); - event(new Verified($user)); - } - - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); - } -} diff --git a/app/Http/Controllers/ConversationController.php b/app/Http/Controllers/ConversationController.php index ea05f0d..921f100 100644 --- a/app/Http/Controllers/ConversationController.php +++ b/app/Http/Controllers/ConversationController.php @@ -3,8 +3,6 @@ namespace App\Http\Controllers; use App\Models\ConversationSession; -use App\Models\ConversationTranscript; -use App\Models\ConversationInsight; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Inertia\Inertia; @@ -22,7 +20,7 @@ public function index() ->paginate(20); return Inertia::render('Conversations/Index', [ - 'sessions' => $sessions + 'sessions' => $sessions, ]); } @@ -64,7 +62,7 @@ public function store(Request $request) return response()->json([ 'session_id' => $session->id, - 'message' => 'Session started successfully' + 'message' => 'Session started successfully', ]); } @@ -104,7 +102,7 @@ public function end(ConversationSession $session, Request $request) ]); return response()->json([ - 'message' => 'Session ended successfully' + 'message' => 'Session ended successfully', ]); } @@ -137,7 +135,7 @@ public function saveTranscript(ConversationSession $session, Request $request) return response()->json([ 'transcript_id' => $transcript->id, - 'message' => 'Transcript saved successfully' + 'message' => 'Transcript saved successfully', ]); } @@ -174,7 +172,7 @@ public function saveBatchTranscripts(ConversationSession $session, Request $requ }); return response()->json([ - 'message' => 'Transcripts saved successfully' + 'message' => 'Transcripts saved successfully', ]); } @@ -199,7 +197,7 @@ public function saveInsight(ConversationSession $session, Request $request) return response()->json([ 'insight_id' => $insight->id, - 'message' => 'Insight saved successfully' + 'message' => 'Insight saved successfully', ]); } @@ -228,7 +226,7 @@ public function saveBatchInsights(ConversationSession $session, Request $request }); return response()->json([ - 'message' => 'Insights saved successfully' + 'message' => 'Insights saved successfully', ]); } @@ -248,7 +246,7 @@ public function updateNotes(ConversationSession $session, Request $request) ]); return response()->json([ - 'message' => 'Notes updated successfully' + 'message' => 'Notes updated successfully', ]); } @@ -268,7 +266,7 @@ public function updateTitle(ConversationSession $session, Request $request) ]); return response()->json([ - 'message' => 'Title updated successfully' + 'message' => 'Title updated successfully', ]); } @@ -284,4 +282,4 @@ public function destroy(ConversationSession $session) return redirect()->route('conversations.index') ->with('message', 'Conversation deleted successfully'); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/RealtimeController.php b/app/Http/Controllers/RealtimeController.php index ebaf88f..b6878e5 100644 --- a/app/Http/Controllers/RealtimeController.php +++ b/app/Http/Controllers/RealtimeController.php @@ -2,47 +2,17 @@ namespace App\Http\Controllers; +use App\Services\ApiKeyService; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use Symfony\Component\HttpFoundation\StreamedResponse; class RealtimeController extends Controller { - /** - * Create a relay connection to OpenAI Realtime API - * This will return WebSocket connection details for the frontend - */ - public function createSession(Request $request) + private ApiKeyService $apiKeyService; + + public function __construct(ApiKeyService $apiKeyService) { - try { - $apiKey = config('openai.api_key'); - - // For now, we'll return connection info that the frontend can use - // In production, you'd want to create a proper WebSocket proxy - return response()->json([ - 'status' => 'success', - 'session' => [ - 'id' => uniqid('session_'), - 'model' => 'gpt-4o-realtime-preview-2024-12-17', - 'instructions' => $request->input('instructions', 'You are an expert sales coach providing real-time guidance.'), - 'voice' => $request->input('voice', 'alloy'), - 'turn_detection' => [ - 'type' => 'server_vad', - 'threshold' => 0.5, - 'prefix_padding_ms' => 300, - 'silence_duration_ms' => 500, - ], - 'tools' => $this->getSalesTools(), - ] - ]); - } catch (\Exception $e) { - Log::error('Failed to create realtime session: ' . $e->getMessage()); - return response()->json([ - 'status' => 'error', - 'message' => 'Failed to create session' - ], 500); - } + $this->apiKeyService = $apiKeyService; } /** @@ -51,180 +21,31 @@ public function createSession(Request $request) public function generateEphemeralKey(Request $request) { try { - $apiKey = config('openai.api_key'); - - // For now, return a mock ephemeral key structure - // In production, you would call OpenAI's API to generate a real ephemeral key - // Example: POST https://api.openai.com/v1/realtime/sessions + $apiKey = $this->apiKeyService->getApiKey(); - // TODO: Implement actual ephemeral key generation when OpenAI provides the endpoint + if (!$apiKey) { + return response()->json([ + 'status' => 'error', + 'message' => 'OpenAI API key not configured', + ], 422); + } + + // Return the actual API key for now + // OpenAI Realtime API uses the API key directly in WebSocket connection return response()->json([ 'status' => 'success', - 'ephemeralKey' => 'ek_' . bin2hex(random_bytes(32)), // Mock ephemeral key + 'ephemeralKey' => $apiKey, // Use actual API key 'expiresAt' => now()->addMinutes(60)->toIso8601String(), 'model' => 'gpt-4o-realtime-preview-2024-12-17', ]); - + } catch (\Exception $e) { - Log::error('Failed to generate ephemeral key: ' . $e->getMessage()); + Log::error('Failed to generate ephemeral key: '.$e->getMessage()); + return response()->json([ 'status' => 'error', - 'message' => 'Failed to generate ephemeral key' + 'message' => 'Failed to generate ephemeral key', ], 500); } } - - /** - * Get the sales-specific tools for the AI - */ - private function getSalesTools() - { - return [ - [ - 'type' => 'function', - 'name' => 'show_teleprompter_script', - 'description' => 'Display a script for the salesperson to read', - 'parameters' => [ - 'type' => 'object', - 'properties' => [ - 'text' => [ - 'type' => 'string', - 'description' => 'The complete script text to display' - ], - 'priority' => [ - 'type' => 'string', - 'enum' => ['high', 'normal', 'low'], - 'description' => 'Priority level of the script' - ], - 'context' => [ - 'type' => 'string', - 'description' => 'Brief context about when to use this script' - ] - ], - 'required' => ['text', 'priority', 'context'] - ] - ], - [ - 'type' => 'function', - 'name' => 'update_sales_metrics', - 'description' => 'Update real-time sales metrics display', - 'parameters' => [ - 'type' => 'object', - 'properties' => [ - 'talkRatio' => [ - 'type' => 'number', - 'description' => 'Percentage of time salesperson is talking (0-100)' - ], - 'sentiment' => [ - 'type' => 'string', - 'enum' => ['positive', 'negative', 'neutral'], - 'description' => 'Current conversation sentiment' - ], - 'topics' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - 'description' => 'Key topics discussed' - ], - 'buyingSignals' => [ - 'type' => 'number', - 'description' => 'Number of buying signals detected' - ] - ], - 'required' => ['talkRatio', 'sentiment'] - ] - ], - [ - 'type' => 'function', - 'name' => 'highlight_opportunity', - 'description' => 'Highlight a sales opportunity or important moment', - 'parameters' => [ - 'type' => 'object', - 'properties' => [ - 'type' => [ - 'type' => 'string', - 'enum' => ['buying_signal', 'objection', 'question', 'closing_opportunity'], - 'description' => 'Type of opportunity' - ], - 'confidence' => [ - 'type' => 'number', - 'description' => 'Confidence level (0-1)' - ], - 'suggestion' => [ - 'type' => 'string', - 'description' => 'Suggested action to take' - ], - 'urgency' => [ - 'type' => 'string', - 'enum' => ['immediate', 'soon', 'low'], - 'description' => 'How urgent is this opportunity' - ] - ], - 'required' => ['type', 'confidence', 'suggestion'] - ] - ], - [ - 'type' => 'function', - 'name' => 'show_battle_card', - 'description' => 'Display competitive information or product details', - 'parameters' => [ - 'type' => 'object', - 'properties' => [ - 'topic' => [ - 'type' => 'string', - 'description' => 'Topic of the battle card' - ], - 'content' => [ - 'type' => 'object', - 'properties' => [ - 'keyPoints' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - 'description' => 'Key talking points' - ], - 'advantages' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - 'description' => 'Our advantages' - ], - 'statistics' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - 'description' => 'Relevant statistics' - ] - ] - ] - ], - 'required' => ['topic', 'content'] - ] - ], - [ - 'type' => 'function', - 'name' => 'calculate_roi', - 'description' => 'Calculate and display ROI for the customer', - 'parameters' => [ - 'type' => 'object', - 'properties' => [ - 'investment' => [ - 'type' => 'number', - 'description' => 'Initial investment amount' - ], - 'savings' => [ - 'type' => 'number', - 'description' => 'Monthly or annual savings' - ], - 'timeframe' => [ - 'type' => 'string', - 'description' => 'Timeframe for ROI calculation' - ], - 'additionalBenefits' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - 'description' => 'Non-monetary benefits' - ] - ], - 'required' => ['investment', 'savings', 'timeframe'] - ] - ] - ]; - } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Settings/ApiKeyController.php b/app/Http/Controllers/Settings/ApiKeyController.php new file mode 100644 index 0000000..bff15e0 --- /dev/null +++ b/app/Http/Controllers/Settings/ApiKeyController.php @@ -0,0 +1,69 @@ +apiKeyService = $apiKeyService; + } + + /** + * Show the API keys settings page + */ + public function edit(Request $request): Response + { + $hasApiKey = $this->apiKeyService->hasApiKey(); + $isUsingEnvKey = !cache()->has('app_openai_api_key') && $hasApiKey; + + return Inertia::render('settings/ApiKeys', [ + 'hasApiKey' => $hasApiKey, + 'isUsingEnvKey' => $isUsingEnvKey, + ]); + } + + /** + * Update the OpenAI API key + */ + public function update(Request $request): RedirectResponse + { + $request->validate([ + 'openai_api_key' => ['required', 'string', 'min:20'], + ]); + + $apiKey = $request->input('openai_api_key'); + + // Validate the API key + if (!$this->apiKeyService->validateApiKey($apiKey)) { + throw ValidationException::withMessages([ + 'openai_api_key' => ['The provided API key is invalid. Please check and try again.'], + ]); + } + + // Store the API key + $this->apiKeyService->setApiKey($apiKey); + + return redirect()->route('api-keys.edit')->with('success', 'API key updated successfully.'); + } + + /** + * Delete the OpenAI API key + */ + public function destroy(Request $request): RedirectResponse + { + $this->apiKeyService->removeApiKey(); + + return redirect()->route('api-keys.edit')->with('success', 'API key deleted successfully.'); + } +} diff --git a/app/Http/Controllers/Settings/PasswordController.php b/app/Http/Controllers/Settings/PasswordController.php deleted file mode 100644 index 33d5a60..0000000 --- a/app/Http/Controllers/Settings/PasswordController.php +++ /dev/null @@ -1,39 +0,0 @@ -validate([ - 'current_password' => ['required', 'current_password'], - 'password' => ['required', Password::defaults(), 'confirmed'], - ]); - - $request->user()->update([ - 'password' => Hash::make($validated['password']), - ]); - - return back(); - } -} diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php deleted file mode 100644 index 10f3d22..0000000 --- a/app/Http/Controllers/Settings/ProfileController.php +++ /dev/null @@ -1,63 +0,0 @@ - $request->user() instanceof MustVerifyEmail, - 'status' => $request->session()->get('status'), - ]); - } - - /** - * Update the user's profile information. - */ - public function update(ProfileUpdateRequest $request): RedirectResponse - { - $request->user()->fill($request->validated()); - - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; - } - - $request->user()->save(); - - return to_route('profile.edit'); - } - - /** - * Delete the user's profile. - */ - public function destroy(Request $request): RedirectResponse - { - $request->validate([ - 'password' => ['required', 'current_password'], - ]); - - $user = $request->user(); - - Auth::logout(); - - $user->delete(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return redirect('/'); - } -} diff --git a/app/Http/Controllers/VariableController.php b/app/Http/Controllers/VariableController.php index 84e204f..e44f9be 100644 --- a/app/Http/Controllers/VariableController.php +++ b/app/Http/Controllers/VariableController.php @@ -2,16 +2,16 @@ namespace App\Http\Controllers; -use App\Services\VariableService; use App\Services\TemplateVariableResolver; -use Illuminate\Http\Request; +use App\Services\VariableService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Inertia\Inertia; -use Inertia\Response; class VariableController extends Controller { private VariableService $variableService; + private TemplateVariableResolver $templateResolver; public function __construct(VariableService $variableService, TemplateVariableResolver $templateResolver) @@ -26,11 +26,11 @@ public function __construct(VariableService $variableService, TemplateVariableRe public function index(Request $request) { $category = $request->get('category'); - - $variables = $category - ? $this->variableService->getByCategory($category) + + $variables = $category + ? $this->variableService->getByCategory($category) : $this->variableService->getAll(); - + $categories = $this->variableService->getCategories(); if ($request->wantsJson()) { @@ -54,7 +54,7 @@ public function store(Request $request): JsonResponse { try { $variable = $this->variableService->upsert($request->all()); - + return response()->json([ 'message' => 'Variable created successfully', 'variable' => $variable, @@ -80,7 +80,7 @@ public function update(Request $request, string $key): JsonResponse try { $data = array_merge($request->all(), ['key' => $key]); $variable = $this->variableService->upsert($data); - + return response()->json([ 'message' => 'Variable updated successfully', 'variable' => $variable, @@ -105,13 +105,13 @@ public function destroy(string $key): JsonResponse { try { $deleted = $this->variableService->delete($key); - - if (!$deleted) { + + if (! $deleted) { return response()->json([ 'message' => 'Variable not found', ], 404); } - + return response()->json([ 'message' => 'Variable deleted successfully', ]); @@ -134,13 +134,13 @@ public function import(Request $request): JsonResponse try { $content = json_decode($request->file('file')->get(), true); - + if (json_last_error() !== JSON_ERROR_NONE) { throw new \Exception('Invalid JSON file'); } - + $this->variableService->import($content); - + return response()->json([ 'message' => 'Variables imported successfully', ]); @@ -164,9 +164,9 @@ public function export(): JsonResponse { try { $data = $this->variableService->export(); - + return response()->json($data) - ->header('Content-Disposition', 'attachment; filename="variables-export-' . now()->format('Y-m-d') . '.json"'); + ->header('Content-Disposition', 'attachment; filename="variables-export-'.now()->format('Y-m-d').'.json"'); } catch (\Exception $e) { return response()->json([ 'message' => 'Failed to export variables', @@ -190,9 +190,9 @@ public function preview(Request $request): JsonResponse $request->input('text'), $request->input('overrides', []) ); - + $extractedVariables = $this->templateResolver->extractVariables($request->input('text')); - + return response()->json([ 'original' => $request->input('text'), 'resolved' => $resolvedText, @@ -213,7 +213,7 @@ public function usage(): JsonResponse { try { $usage = $this->templateResolver->getAllUsedVariables(); - + return response()->json([ 'usage' => $usage, ]); @@ -232,7 +232,7 @@ public function seedDefaults(): JsonResponse { try { $this->variableService->seedDefaults(); - + return response()->json([ 'message' => 'Default variables seeded successfully', ]); @@ -243,4 +243,4 @@ public function seedDefaults(): JsonResponse ], 500); } } -} \ No newline at end of file +} diff --git a/app/Models/ContextSnapshot.php b/app/Models/ContextSnapshot.php deleted file mode 100644 index 54554a5..0000000 --- a/app/Models/ContextSnapshot.php +++ /dev/null @@ -1,26 +0,0 @@ - 'array', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - ]; - - public function conversations() - { - return $this->belongsToMany(Conversation::class, 'conversation_context_snapshot'); - } -} diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php deleted file mode 100644 index 932fd30..0000000 --- a/app/Models/Conversation.php +++ /dev/null @@ -1,32 +0,0 @@ - 'array', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - ]; - - public function getContextSnapshotsAttribute() - { - if (empty($this->context_snapshot_ids)) { - return collect(); - } - - return ContextSnapshot::whereIn('id', $this->context_snapshot_ids)->get(); - } -} diff --git a/app/Models/ConversationSession.php b/app/Models/ConversationSession.php index ee62168..c933eea 100644 --- a/app/Models/ConversationSession.php +++ b/app/Models/ConversationSession.php @@ -80,6 +80,7 @@ public function getDurationFormattedAttribute(): string { $minutes = floor($this->duration_seconds / 60); $seconds = $this->duration_seconds % 60; + return sprintf('%02d:%02d', $minutes, $seconds); } } diff --git a/app/Models/ConversationTranscript.php b/app/Models/ConversationTranscript.php index 0c367ad..f527c52 100644 --- a/app/Models/ConversationTranscript.php +++ b/app/Models/ConversationTranscript.php @@ -29,7 +29,7 @@ public function session(): BelongsTo public function getSpeakerLabelAttribute(): string { - return match($this->speaker) { + return match ($this->speaker) { 'salesperson' => 'You', 'customer' => 'Customer', 'system' => 'System', @@ -39,7 +39,7 @@ public function getSpeakerLabelAttribute(): string public function getSpeakerColorAttribute(): string { - return match($this->speaker) { + return match ($this->speaker) { 'salesperson' => 'text-blue-600', 'customer' => 'text-green-600', 'system' => 'text-gray-500', diff --git a/app/Models/Transcript.php b/app/Models/Transcript.php index 4945895..f3e9f69 100644 --- a/app/Models/Transcript.php +++ b/app/Models/Transcript.php @@ -57,26 +57,26 @@ public function appendTranscript(string $text): void public function appendSegment(string $text, string $speaker = 'unknown', ?float $timestamp = null): void { $segments = $this->segments ?? []; - + $segment = [ 'speaker' => $speaker, 'text' => $text, 'timestamp' => $timestamp ?? now()->timestamp, ]; - + $segments[] = $segment; $this->segments = $segments; - + // Update speaker-specific transcripts if ($speaker === 'host') { $this->host_transcript = trim(($this->host_transcript ?? '').' '.$text); } elseif ($speaker === 'guest') { $this->guest_transcript = trim(($this->guest_transcript ?? '').' '.$text); } - + // Update combined transcript with speaker labels $this->transcript = trim($this->transcript.' ['.$speaker.'] '.$text); - + $this->save(); } diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..124a600 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -21,6 +21,8 @@ class User extends Authenticatable 'name', 'email', 'password', + 'openai_api_key', + 'has_completed_onboarding', ]; /** @@ -31,6 +33,7 @@ class User extends Authenticatable protected $hidden = [ 'password', 'remember_token', + 'openai_api_key', ]; /** @@ -43,6 +46,8 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'openai_api_key' => 'encrypted', + 'has_completed_onboarding' => 'boolean', ]; } } diff --git a/app/Models/Variable.php b/app/Models/Variable.php index ce11ebc..c80fa7b 100644 --- a/app/Models/Variable.php +++ b/app/Models/Variable.php @@ -66,7 +66,7 @@ public function validate(): bool ['value' => $this->validation_rules] ); - return !$validator->fails(); + return ! $validator->fails(); } /** @@ -92,4 +92,4 @@ public function scopeUser($query) { return $query->where('is_system', false); } -} \ No newline at end of file +} diff --git a/app/Providers/NativeAppServiceProvider.php b/app/Providers/NativeAppServiceProvider.php index 73758dd..b2657db 100644 --- a/app/Providers/NativeAppServiceProvider.php +++ b/app/Providers/NativeAppServiceProvider.php @@ -2,12 +2,12 @@ namespace App\Providers; -use Native\Laravel\Contracts\ProvidesPhpIni; +use App\Models\User; // use Native\Laravel\Facades\GlobalShortcut; -use Native\Laravel\Facades\Window; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Schema; -use App\Models\User; +use Native\Laravel\Contracts\ProvidesPhpIni; +use Native\Laravel\Facades\Window; class NativeAppServiceProvider implements ProvidesPhpIni { @@ -34,7 +34,7 @@ public function boot(): void 'contextIsolation' => false, 'webSecurity' => false, 'backgroundThrottling' => false, - 'sandbox' => false + 'sandbox' => false, ]) // Set window to floating panel level for better screen protection ->alwaysOnTop(false); diff --git a/app/Services/AIService.php b/app/Services/AIService.php deleted file mode 100644 index 9ed32e3..0000000 --- a/app/Services/AIService.php +++ /dev/null @@ -1,178 +0,0 @@ - 'Bearer ' . $apiKey, - 'Content-Type' => 'application/json', - ])->post('https://api.openai.com/v1/chat/completions', [ - 'model' => $this->defaultModel, - 'messages' => [ - ['role' => 'system', 'content' => 'You are a helpful AI assistant.'], - ['role' => 'user', 'content' => $message] - ], - 'max_tokens' => 1000, - 'temperature' => 0.7, - ]); - - if (!$response->successful()) { - throw new \Exception('OpenAI API error: ' . $response->body()); - } - - $data = $response->json(); - - return [ - 'content' => $data['choices'][0]['message']['content'] ?? 'No response', - 'model' => $this->defaultModel, - 'provider' => 'openai', - ]; - } catch (\Exception $e) { - Log::error('AI Service error: ' . $e->getMessage()); - throw $e; - } - } - - /** - * Transcribe audio to text using OpenAI Whisper - */ - public function transcribe(string $audioData): array - { - try { - $apiKey = config('openai.api_key'); - if (empty($apiKey)) { - throw new \Exception('OpenAI API key is not configured. Please add OPENAI_API_KEY to your .env file.'); - } - - // Convert base64 audio to file - $audioContent = base64_decode($audioData); - $tempFile = tempnam(sys_get_temp_dir(), 'audio_') . '.webm'; - file_put_contents($tempFile, $audioContent); - - // Send to OpenAI Whisper API - $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $apiKey, - ])->timeout(5)->attach( - 'file', file_get_contents($tempFile), 'audio.webm' - )->post('https://api.openai.com/v1/audio/transcriptions', [ - 'model' => 'whisper-1', - 'language' => 'en', - 'response_format' => 'text', - ]); - - // Clean up temp file - unlink($tempFile); - - if (!$response->successful()) { - throw new \Exception('Whisper API error: ' . $response->body()); - } - - // Response is plain text when using response_format: 'text' - $text = $response->body(); - - return [ - 'text' => trim($text), - ]; - } catch (\Exception $e) { - Log::error('Transcription error: ' . $e->getMessage()); - throw $e; - } - } - - /** - * Analyze sales conversation and provide coaching suggestions - */ - public function analyzeSalesConversation(string $newTranscript, string $context = ''): array - { - try { - $apiKey = config('openai.api_key'); - if (empty($apiKey)) { - throw new \Exception('OpenAI API key is not configured.'); - } - - $systemPrompt = "You are an expert sales coach AI assistant. Analyze the conversation and provide real-time coaching for the salesperson. - -For talking-points, provide COMPLETE, NATURAL SENTENCES that the salesperson can read word-for-word, like a teleprompter. Make them sound conversational and human - not robotic. Include transitions, acknowledgments, and natural flow. - -Focus on: -1. Customer objections → Provide empathetic responses with value propositions -2. Buying signals → Give closing scripts -3. Pain points → Offer discovery questions and solutions -4. Silence/lulls → Suggest engaging questions or value statements - -Example talking-point scripts: -- \"I completely understand your concern about the budget. Many of our clients felt the same way initially, but they found that the time savings alone - typically 10 hours per week - more than justified the investment.\" -- \"That's a great question about integration. Let me walk you through exactly how seamless the process is - it typically takes less than 30 minutes to set up.\" - -Return JSON format: -{ - \"suggestions\": [ - { - \"type\": \"action\"|\"talking-point\"|\"insight\", - \"priority\": \"high\"|\"normal\", - \"title\": \"Brief context (e.g., 'Budget Objection Response')\", - \"content\": \"Complete teleprompter script for talking-points, or action description\" - } - ], - \"metrics\": { - \"talkRatio\": 50, - \"sentiment\": \"positive\"|\"negative\"|\"neutral\", - \"topics\": [\"key topics discussed\"] - } -} - -Generate 1-2 high-priority talking-points max. Keep scripts concise but complete."; - - $userPrompt = "New transcript:\n$newTranscript\n\nPrevious context:\n$context\n\nAnalyze this sales conversation and provide coaching suggestions."; - - $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $apiKey, - 'Content-Type' => 'application/json', - ])->timeout(5)->post('https://api.openai.com/v1/chat/completions', [ - 'model' => 'gpt-4o-mini', - 'messages' => [ - ['role' => 'system', 'content' => $systemPrompt], - ['role' => 'user', 'content' => $userPrompt] - ], - 'max_tokens' => 200, - 'temperature' => 0.3, - 'response_format' => ['type' => 'json_object'] - ]); - - if (!$response->successful()) { - throw new \Exception('OpenAI API error: ' . $response->body()); - } - - $data = $response->json(); - $content = $data['choices'][0]['message']['content'] ?? '{}'; - - return json_decode($content, true) ?: [ - 'suggestions' => [], - 'metrics' => null - ]; - } catch (\Exception $e) { - Log::error('Sales analysis error: ' . $e->getMessage()); - return [ - 'suggestions' => [], - 'metrics' => null - ]; - } - } -} \ No newline at end of file diff --git a/app/Services/ApiKeyService.php b/app/Services/ApiKeyService.php new file mode 100644 index 0000000..0ff4d50 --- /dev/null +++ b/app/Services/ApiKeyService.php @@ -0,0 +1,66 @@ +getApiKey()); + } + + /** + * Validate an API key with OpenAI + */ + public function validateApiKey(string $apiKey): bool + { + try { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $apiKey, + ])->get('https://api.openai.com/v1/models'); + + return $response->successful(); + } catch (\Exception $e) { + return false; + } + } +} \ No newline at end of file diff --git a/app/Services/RealtimeRelayService.php b/app/Services/RealtimeRelayService.php index 4984a8f..fb11edf 100644 --- a/app/Services/RealtimeRelayService.php +++ b/app/Services/RealtimeRelayService.php @@ -2,50 +2,65 @@ namespace App\Services; -use Ratchet\MessageComponentInterface; +use App\Models\User; +use Illuminate\Support\Facades\Log; use Ratchet\ConnectionInterface; +use Ratchet\MessageComponentInterface; use WebSocket\Client as WebSocketClient; -use Illuminate\Support\Facades\Log; class RealtimeRelayService implements MessageComponentInterface { protected $clients; + protected $openaiConnections = []; + private string $openaiRealtimeUrl = 'wss://api.openai.com/v1/realtime'; - private string $openaiApiKey; public function __construct() { $this->clients = new \SplObjectStorage; - $this->openaiApiKey = config('openai.api_key'); + } + + /** + * Get the API key - prefer user's key over env key + */ + private function getApiKey(?User $user = null): ?string + { + // Check if there's a user with an API key + if ($user && $user->openai_api_key) { + return $user->openai_api_key; + } + + // Fall back to environment variable + return config('openai.api_key'); } public function onOpen(ConnectionInterface $conn) { // Store the new connection $this->clients->attach($conn); - + // Create a connection to OpenAI for this client $this->connectToOpenAI($conn); - + Log::info("New WebSocket connection: {$conn->resourceId}"); } public function onMessage(ConnectionInterface $from, $msg) { $data = json_decode($msg, true); - + // Relay message to OpenAI if (isset($this->openaiConnections[$from->resourceId])) { $openaiConn = $this->openaiConnections[$from->resourceId]; - + // Add authentication if this is a session update if ($data['type'] === 'session.update') { $data['session']['model'] = 'gpt-4o-realtime-preview-2024-12-17'; } - + $openaiConn->send(json_encode($data)); - + Log::info("Relayed message to OpenAI from client {$from->resourceId}"); } } @@ -57,10 +72,10 @@ public function onClose(ConnectionInterface $conn) $this->openaiConnections[$conn->resourceId]->close(); unset($this->openaiConnections[$conn->resourceId]); } - + // Remove client $this->clients->detach($conn); - + Log::info("Connection {$conn->resourceId} disconnected"); } @@ -73,33 +88,40 @@ public function onError(ConnectionInterface $conn, \Exception $e) private function connectToOpenAI(ConnectionInterface $clientConn) { try { + // Get API key (for now, use the env key - in a real app, you'd pass user info) + $apiKey = $this->getApiKey(); + + if (empty($apiKey)) { + throw new \Exception('No OpenAI API key available'); + } + // Create WebSocket connection to OpenAI $headers = [ - 'Authorization' => 'Bearer ' . $this->openaiApiKey, + 'Authorization' => 'Bearer '.$apiKey, 'OpenAI-Beta' => 'realtime=v1', ]; - + $openaiConn = new WebSocketClient($this->openaiRealtimeUrl, [ 'timeout' => 60, 'headers' => $headers, ]); - + // Store the connection $this->openaiConnections[$clientConn->resourceId] = $openaiConn; - + // Set up message handler for OpenAI responses $this->setupOpenAIHandlers($clientConn, $openaiConn); - + Log::info("Connected to OpenAI Realtime API for client {$clientConn->resourceId}"); - + } catch (\Exception $e) { Log::error("Failed to connect to OpenAI: {$e->getMessage()}"); $clientConn->send(json_encode([ 'type' => 'error', 'error' => [ 'message' => 'Failed to connect to OpenAI Realtime API', - 'details' => $e->getMessage() - ] + 'details' => $e->getMessage(), + ], ])); } } @@ -109,15 +131,15 @@ private function setupOpenAIHandlers(ConnectionInterface $clientConn, WebSocketC // Start a loop to receive messages from OpenAI // This would typically run in a separate thread/process // For now, we'll handle it synchronously - + try { while ($clientConn->resourceId && isset($this->openaiConnections[$clientConn->resourceId])) { $message = $openaiConn->receive(); - + if ($message) { // Relay OpenAI response back to client $clientConn->send($message); - + Log::info("Relayed OpenAI response to client {$clientConn->resourceId}"); } } @@ -125,4 +147,4 @@ private function setupOpenAIHandlers(ConnectionInterface $clientConn, WebSocketC Log::error("Error in OpenAI message loop: {$e->getMessage()}"); } } -} \ No newline at end of file +} diff --git a/app/Services/TemplateVariableResolver.php b/app/Services/TemplateVariableResolver.php index eefca5c..be7584d 100644 --- a/app/Services/TemplateVariableResolver.php +++ b/app/Services/TemplateVariableResolver.php @@ -17,47 +17,47 @@ public function __construct(VariableService $variableService) /** * Resolve variables in a template * - * @param Template $template The template to resolve - * @param array $contextVariables Context-specific variables that override defaults + * @param Template $template The template to resolve + * @param array $contextVariables Context-specific variables that override defaults * @return array{prompt: string, variables: array, missing: array} */ public function resolve(Template $template, array $contextVariables = []): array { // Get global variables $globalVariables = $this->variableService->getVariablesAsArray(); - + // Get template-specific variables $templateVariables = $template->variables ?? []; - + // Merge variables (priority: context > template > global) $mergedVariables = array_merge($globalVariables, $templateVariables, $contextVariables); - + // Extract variable placeholders from the prompt $requiredVariables = $this->extractVariables($template->prompt); - + // Check for missing variables $missingVariables = array_diff($requiredVariables, array_keys($mergedVariables)); - + // Replace variables in the prompt $resolvedPrompt = $this->variableService->replaceInText($template->prompt, $mergedVariables); - + // Also resolve variables in talking points if they exist $resolvedTalkingPoints = []; - if (!empty($template->talking_points)) { + if (! empty($template->talking_points)) { foreach ($template->talking_points as $point) { $resolvedTalkingPoints[] = $this->variableService->replaceInText($point, $mergedVariables); } } - + // Log resolution details for debugging - if (!empty($missingVariables)) { + if (! empty($missingVariables)) { Log::warning('Missing template variables', [ 'template_id' => $template->id, 'template_name' => $template->name, 'missing' => $missingVariables, ]); } - + return [ 'prompt' => $resolvedPrompt, 'variables' => $mergedVariables, @@ -69,75 +69,68 @@ public function resolve(Template $template, array $contextVariables = []): array /** * Extract variable placeholders from text * - * @param string $text The text to extract variables from + * @param string $text The text to extract variables from * @return array List of variable names found in the text */ public function extractVariables(string $text): array { $variables = []; - + // Match {variable_name} pattern preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', $text, $matches); - - if (!empty($matches[1])) { + + if (! empty($matches[1])) { $variables = array_unique($matches[1]); } - + return $variables; } /** * Preview how a template would look with current variables * - * @param Template $template - * @param array $overrides Variable overrides for preview - * @return array + * @param array $overrides Variable overrides for preview */ public function preview(Template $template, array $overrides = []): array { $result = $this->resolve($template, $overrides); - + // Add additional preview information $result['preview'] = [ 'original_prompt' => $template->prompt, 'resolved_prompt' => $result['prompt'], 'used_variables' => array_intersect_key( - $result['variables'], + $result['variables'], array_flip($this->extractVariables($template->prompt)) ), 'all_available_variables' => array_keys($result['variables']), ]; - + return $result; } /** * Validate that a template has all required variables - * - * @param Template $template - * @param array $providedVariables - * @return bool */ public function validateTemplate(Template $template, array $providedVariables = []): bool { $result = $this->resolve($template, $providedVariables); + return empty($result['missing']); } /** * Get all variables used across all templates - * - * @return array */ public function getAllUsedVariables(): array { $usedVariables = []; - + $templates = Template::all(); foreach ($templates as $template) { $variables = $this->extractVariables($template->prompt); foreach ($variables as $var) { - if (!isset($usedVariables[$var])) { + if (! isset($usedVariables[$var])) { $usedVariables[$var] = [ 'variable' => $var, 'used_in' => [], @@ -149,20 +142,17 @@ public function getAllUsedVariables(): array ]; } } - + return array_values($usedVariables); } /** * Suggest variables based on template content - * - * @param string $promptText - * @return array */ public function suggestVariables(string $promptText): array { $suggestions = []; - + // Common patterns to look for $patterns = [ '/\$[\d,]+/' => 'pricing', @@ -173,13 +163,13 @@ public function suggestVariables(string $promptText): array '/\b(feature|benefit|capability)\b/i' => 'feature', '/\b(support|help|assist)\b/i' => 'support', ]; - + foreach ($patterns as $pattern => $type) { if (preg_match($pattern, $promptText)) { $suggestions[] = $type; } } - + // Get existing variables by suggested types $existingVariables = []; foreach (array_unique($suggestions) as $type) { @@ -193,7 +183,7 @@ public function suggestVariables(string $promptText): array ]; } } - + return $existingVariables; } -} \ No newline at end of file +} diff --git a/app/Services/VariableService.php b/app/Services/VariableService.php index d024ead..5a8681c 100644 --- a/app/Services/VariableService.php +++ b/app/Services/VariableService.php @@ -13,7 +13,7 @@ class VariableService * Cache key prefix for variables */ private const CACHE_PREFIX = 'variables:'; - + /** * Cache duration in seconds (1 hour) */ @@ -24,7 +24,7 @@ class VariableService */ public function getAll(): Collection { - return Cache::remember(self::CACHE_PREFIX . 'all', self::CACHE_TTL, function () { + return Cache::remember(self::CACHE_PREFIX.'all', self::CACHE_TTL, function () { return Variable::orderBy('category')->orderBy('key')->get(); }); } @@ -34,7 +34,7 @@ public function getAll(): Collection */ public function getByCategory(string $category): Collection { - return Cache::remember(self::CACHE_PREFIX . "category:{$category}", self::CACHE_TTL, function () use ($category) { + return Cache::remember(self::CACHE_PREFIX."category:{$category}", self::CACHE_TTL, function () use ($category) { return Variable::byCategory($category)->orderBy('key')->get(); }); } @@ -44,7 +44,7 @@ public function getByCategory(string $category): Collection */ public function getByKey(string $key): ?Variable { - return Cache::remember(self::CACHE_PREFIX . "key:{$key}", self::CACHE_TTL, function () use ($key) { + return Cache::remember(self::CACHE_PREFIX."key:{$key}", self::CACHE_TTL, function () use ($key) { return Variable::where('key', $key)->first(); }); } @@ -86,8 +86,8 @@ public function upsert(array $data): Variable public function delete(string $key): bool { $variable = Variable::where('key', $key)->first(); - - if (!$variable) { + + if (! $variable) { return false; } @@ -111,7 +111,7 @@ public function replaceInText(string $text, array $overrides = []): string { // Get all variables as key-value pairs $variables = $this->getVariablesAsArray(); - + // Merge with overrides (overrides take precedence) $variables = array_merge($variables, $overrides); @@ -125,7 +125,7 @@ public function replaceInText(string $text, array $overrides = []): string }; // Replace {key} pattern - $text = str_replace('{' . $key . '}', $replacement, $text); + $text = str_replace('{'.$key.'}', $replacement, $text); } return $text; @@ -137,14 +137,14 @@ public function replaceInText(string $text, array $overrides = []): string public function validate(string $key, $value): bool { $variable = $this->getByKey($key); - - if (!$variable) { + + if (! $variable) { return false; } // Set the value temporarily for validation $variable->value = $value; - + return $variable->validate(); } @@ -154,7 +154,7 @@ public function validate(string $key, $value): bool public function export(): array { $variables = Variable::all(); - + return [ 'version' => '1.0', 'exported_at' => now()->toIso8601String(), @@ -190,7 +190,7 @@ public function import(array $data): void DB::transaction(function () use ($data) { foreach ($data['variables'] as $variableData) { // Skip system variables unless explicitly allowed - if (($variableData['is_system'] ?? false) && !config('app.allow_system_variable_import', false)) { + if (($variableData['is_system'] ?? false) && ! config('app.allow_system_variable_import', false)) { continue; } @@ -207,7 +207,7 @@ public function import(array $data): void */ public function getVariablesAsArray(): array { - return Cache::remember(self::CACHE_PREFIX . 'array', self::CACHE_TTL, function () { + return Cache::remember(self::CACHE_PREFIX.'array', self::CACHE_TTL, function () { return Variable::all()->pluck('typed_value', 'key')->toArray(); }); } @@ -217,7 +217,7 @@ public function getVariablesAsArray(): array */ public function getCategories(): Collection { - return Cache::remember(self::CACHE_PREFIX . 'categories', self::CACHE_TTL, function () { + return Cache::remember(self::CACHE_PREFIX.'categories', self::CACHE_TTL, function () { return Variable::distinct('category')->pluck('category')->sort(); }); } @@ -228,13 +228,13 @@ public function getCategories(): Collection private function clearCache(?Variable $variable = null): void { // Clear all variable caches - Cache::forget(self::CACHE_PREFIX . 'all'); - Cache::forget(self::CACHE_PREFIX . 'array'); - Cache::forget(self::CACHE_PREFIX . 'categories'); - + Cache::forget(self::CACHE_PREFIX.'all'); + Cache::forget(self::CACHE_PREFIX.'array'); + Cache::forget(self::CACHE_PREFIX.'categories'); + if ($variable) { - Cache::forget(self::CACHE_PREFIX . "key:{$variable->key}"); - Cache::forget(self::CACHE_PREFIX . "category:{$variable->category}"); + Cache::forget(self::CACHE_PREFIX."key:{$variable->key}"); + Cache::forget(self::CACHE_PREFIX."category:{$variable->category}"); } } @@ -326,4 +326,4 @@ public function seedDefaults(): void ); } } -} \ No newline at end of file +} diff --git a/bootstrap/app.php b/bootstrap/app.php index ed5ad8e..57cea11 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -21,7 +21,7 @@ HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); - + $middleware->validateCsrfTokens(except: [ 'api/realtime/*', ]); diff --git a/config/nativephp.php b/config/nativephp.php index 8351195..3d3e458 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -13,7 +13,8 @@ * usually in the form of a reverse domain name. * For example: com.nativephp.app */ - 'app_id' => env('NATIVEPHP_APP_ID', 'com.nativephp.app'), + ' + ' => env('NATIVEPHP_APP_ID', 'com.nativephp.app'), /** * If your application allows deep linking, you can specify the scheme @@ -63,6 +64,8 @@ 'GITHUB_*', 'DO_SPACES_*', '*_SECRET', + 'OPENAI_API_KEY', + 'VITE_OPENAI_API_KEY', 'ZEPHPYR_*', 'NATIVEPHP_UPDATER_PATH', 'NATIVEPHP_APPLE_ID', diff --git a/database/migrations/2025_07_07_000045_create_conversation_sessions_table.php b/database/migrations/2025_07_07_000045_create_conversation_sessions_table.php index 60cbd6a..2553180 100644 --- a/database/migrations/2025_07_07_000045_create_conversation_sessions_table.php +++ b/database/migrations/2025_07_07_000045_create_conversation_sessions_table.php @@ -21,26 +21,26 @@ public function up(): void $table->dateTime('ended_at')->nullable(); $table->integer('duration_seconds')->default(0); $table->string('template_used')->nullable(); - + // Customer Intelligence Summary $table->string('final_intent')->nullable(); $table->string('final_buying_stage')->nullable(); $table->integer('final_engagement_level')->default(50); $table->string('final_sentiment')->nullable(); - + // Conversation Metrics $table->integer('total_transcripts')->default(0); $table->integer('total_insights')->default(0); $table->integer('total_topics')->default(0); $table->integer('total_commitments')->default(0); $table->integer('total_action_items')->default(0); - + // Summary and notes $table->text('ai_summary')->nullable(); $table->text('user_notes')->nullable(); - + $table->timestamps(); - + // Indexes $table->index(['user_id', 'started_at']); $table->index('customer_name'); diff --git a/database/migrations/2025_07_07_000049_create_conversation_transcripts_table.php b/database/migrations/2025_07_07_000049_create_conversation_transcripts_table.php index bd8ca44..aa5e0cc 100644 --- a/database/migrations/2025_07_07_000049_create_conversation_transcripts_table.php +++ b/database/migrations/2025_07_07_000049_create_conversation_transcripts_table.php @@ -21,7 +21,7 @@ public function up(): void $table->string('system_category')->nullable(); // For system messages: info, warning, success, error $table->integer('order_index'); // For maintaining order $table->timestamps(); - + // Indexes $table->index(['session_id', 'spoken_at']); $table->index(['session_id', 'order_index']); diff --git a/database/migrations/2025_07_07_000053_create_conversation_insights_table.php b/database/migrations/2025_07_07_000053_create_conversation_insights_table.php index ceb533d..929ec47 100644 --- a/database/migrations/2025_07_07_000053_create_conversation_insights_table.php +++ b/database/migrations/2025_07_07_000053_create_conversation_insights_table.php @@ -18,7 +18,7 @@ public function up(): void $table->json('data'); // Store the full object data $table->timestamp('captured_at'); $table->timestamps(); - + // Indexes $table->index(['session_id', 'insight_type']); $table->index(['session_id', 'captured_at']); diff --git a/database/migrations/2025_07_08_040023_create_variables_table.php b/database/migrations/2025_07_08_040023_create_variables_table.php index 3b6ca22..bed94af 100644 --- a/database/migrations/2025_07_08_040023_create_variables_table.php +++ b/database/migrations/2025_07_08_040023_create_variables_table.php @@ -21,7 +21,7 @@ public function up(): void $table->boolean('is_system')->default(false); $table->json('validation_rules')->nullable(); $table->timestamps(); - + // Indexes for performance $table->index('category'); $table->index('is_system'); diff --git a/database/migrations/2025_07_13_153706_drop_assistant_tables.php b/database/migrations/2025_07_13_153706_drop_assistant_tables.php new file mode 100644 index 0000000..84fe1dd --- /dev/null +++ b/database/migrations/2025_07_13_153706_drop_assistant_tables.php @@ -0,0 +1,48 @@ +id(); + $table->enum('type', ['screenshot', 'audio', 'text']); + $table->longText('content')->nullable(); + $table->json('metadata')->nullable(); + $table->binary('embedding')->nullable(); + $table->timestamps(); + $table->index('type'); + $table->index('created_at'); + }); + + // Recreate conversations table + Schema::create('conversations', function (Blueprint $table) { + $table->id(); + $table->json('context_snapshot_ids')->nullable(); + $table->longText('user_message'); + $table->longText('assistant_response')->nullable(); + $table->string('model')->nullable(); + $table->string('provider')->nullable(); + $table->integer('tokens_used')->nullable(); + $table->timestamps(); + $table->index('created_at'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index cd9b871..b05711d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,8 +2,6 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -13,14 +11,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - - // Seed templates $this->call([ MeetingTemplateSeeder::class, ]); diff --git a/database/seeders/MeetingTemplateSeeder.php b/database/seeders/MeetingTemplateSeeder.php index 5006fd6..c4d5395 100644 --- a/database/seeders/MeetingTemplateSeeder.php +++ b/database/seeders/MeetingTemplateSeeder.php @@ -59,14 +59,14 @@ public function run(): void 'product_name' => 'Clueless', 'base_price' => '$49/user/month', 'team_price' => '$39/user/month', - 'trial_length' => '14 days' + 'trial_length' => '14 days', ], 'talking_points' => [ 'How do you currently handle meeting notes?', 'What challenges do you face with action item tracking?', 'How many meetings does your team have weekly?', 'What happens when key people miss meetings?', - 'How do you ensure nothing falls through the cracks?' + 'How do you ensure nothing falls through the cracks?', ], 'icon' => 'Phone', 'category' => 'sales_coach', @@ -119,7 +119,7 @@ public function run(): void 'variables' => [ 'demo_length' => '30 minutes', 'key_features' => '5 core features', - 'roi_multiplier' => '10x in 3 months' + 'roi_multiplier' => '10x in 3 months', ], 'talking_points' => [ 'Live transcription accuracy demonstration', @@ -127,7 +127,7 @@ public function run(): void 'Integration with existing tools', 'Security and compliance features', 'Team collaboration capabilities', - 'Analytics and insights dashboard' + 'Analytics and insights dashboard', ], 'icon' => 'Monitor', 'category' => 'sales_coach', @@ -184,7 +184,7 @@ public function run(): void 'starter_price' => '$49/user/month', 'team_price' => '$39/user/month', 'enterprise_price' => '$29/user/month', - 'annual_discount' => '20%' + 'annual_discount' => '20%', ], 'talking_points' => [ 'ROI calculation based on time saved', @@ -192,7 +192,7 @@ public function run(): void 'Volume discounts available', 'Flexible payment terms', 'Success stories from similar companies', - 'Risk-free trial period' + 'Risk-free trial period', ], 'icon' => 'DollarSign', 'category' => 'sales_coach', @@ -251,7 +251,7 @@ public function run(): void 'variables' => [ 'health_score' => 'Green/Yellow/Red', 'adoption_rate' => 'X% of licensed users', - 'expansion_potential' => 'Additional teams/features' + 'expansion_potential' => 'Additional teams/features', ], 'talking_points' => [ 'Usage metrics and adoption rates', @@ -259,7 +259,7 @@ public function run(): void 'Optimization opportunities', 'New features and updates', 'Team expansion possibilities', - 'Advanced training options' + 'Advanced training options', ], 'icon' => 'Heart', 'category' => 'customer_success', @@ -319,7 +319,7 @@ public function run(): void 'variables' => [ 'api_rate_limit' => '1000-unlimited/hour', 'setup_time' => '1 day - 6 weeks', - 'security_cert' => 'SOC2, HIPAA' + 'security_cert' => 'SOC2, HIPAA', ], 'talking_points' => [ 'API documentation walkthrough', @@ -327,7 +327,7 @@ public function run(): void 'Integration architecture', 'Data flow and storage', 'Authentication methods', - 'Sandbox environment access' + 'Sandbox environment access', ], 'icon' => 'Code', 'category' => 'technical', @@ -386,7 +386,7 @@ public function run(): void 'variables' => [ 'roi_timeline' => '3 months', 'productivity_gain' => '40%', - 'strategic_value' => 'Digital transformation enabler' + 'strategic_value' => 'Digital transformation enabler', ], 'talking_points' => [ 'Strategic vision alignment', @@ -394,7 +394,7 @@ public function run(): void 'Organization-wide impact', 'Success metrics and KPIs', 'Implementation roadmap', - 'Expected ROI and timeline' + 'Expected ROI and timeline', ], 'icon' => 'Briefcase', 'category' => 'executive', @@ -454,7 +454,7 @@ public function run(): void 'variables' => [ 'response_sla' => '24 hours', 'critical_sla' => '4 hours', - 'satisfaction_target' => '95%' + 'satisfaction_target' => '95%', ], 'talking_points' => [ 'Issue validation and reproduction', @@ -462,7 +462,7 @@ public function run(): void 'Root cause analysis', 'Prevention measures', 'Follow-up timeline', - 'Escalation if needed' + 'Escalation if needed', ], 'icon' => 'Wrench', 'category' => 'support', @@ -520,7 +520,7 @@ public function run(): void 'variables' => [ 'referral_commission' => '20% year 1', 'reseller_discount' => '30%', - 'integration_timeline' => '60-90 days' + 'integration_timeline' => '60-90 days', ], 'talking_points' => [ 'Partnership value proposition', @@ -528,7 +528,7 @@ public function run(): void 'Go-to-market strategy', 'Revenue models', 'Success metrics', - 'Next steps and timeline' + 'Next steps and timeline', ], 'icon' => 'Users', 'category' => 'partnership', @@ -587,14 +587,14 @@ public function run(): void 'variables' => [ 'meeting_duration' => '15-30 minutes', 'team_size' => 'Varies', - 'format' => 'Yesterday/Today/Blockers' + 'format' => 'Yesterday/Today/Blockers', ], 'talking_points' => [ 'What did you complete yesterday?', 'What are you working on today?', 'Any blockers or impediments?', 'Do you need help from anyone?', - 'Are we on track for sprint goals?' + 'Are we on track for sprint goals?', ], 'icon' => 'Users', 'category' => 'team_meeting', @@ -665,7 +665,7 @@ public function run(): void 'variables' => [ 'review_period' => 'Quarterly', 'planning_horizon' => 'Next 90 days', - 'key_metrics' => 'Revenue, NPS, Retention' + 'key_metrics' => 'Revenue, NPS, Retention', ], 'talking_points' => [ 'Q3 performance vs targets', @@ -675,7 +675,7 @@ public function run(): void 'Q4 priorities and goals', 'Resource requirements', 'Risk mitigation strategies', - 'Success metrics for Q4' + 'Success metrics for Q4', ], 'icon' => 'Calendar', 'category' => 'strategic', @@ -687,4 +687,4 @@ public function run(): void Template::create($template); } } -} \ No newline at end of file +} diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 1c87dc2..3c47f1c 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -4,7 +4,7 @@ import NavMain from '@/components/NavMain.vue'; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/vue3'; -import { BookOpen, FileText, Folder, MessageCircle, Phone, Variable } from 'lucide-vue-next'; +import { BookOpen, FileText, Folder, MessageCircle, Phone, Settings, Variable } from 'lucide-vue-next'; import AppLogo from './AppLogo.vue'; const mainNavItems: NavItem[] = [ @@ -28,6 +28,11 @@ const mainNavItems: NavItem[] = [ href: '/variables', icon: Variable, }, + { + title: 'Settings', + href: '/settings/api-keys', + icon: Settings, + }, ]; const footerNavItems: NavItem[] = [ diff --git a/resources/js/components/AudioRecorder.vue b/resources/js/components/AudioRecorder.vue deleted file mode 100644 index b6d78be..0000000 --- a/resources/js/components/AudioRecorder.vue +++ /dev/null @@ -1,271 +0,0 @@ - - - - - - - - - {{ formatTime(recordingTime) }} - - Transcribing... - - - - - - diff --git a/resources/js/components/UserMenuContent.vue b/resources/js/components/UserMenuContent.vue index 7818444..ae3f142 100644 --- a/resources/js/components/UserMenuContent.vue +++ b/resources/js/components/UserMenuContent.vue @@ -2,17 +2,13 @@ import UserInfo from '@/components/UserInfo.vue'; import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; import type { User } from '@/types'; -import { Link, router } from '@inertiajs/vue3'; -import { LogOut, Settings } from 'lucide-vue-next'; +import { Link } from '@inertiajs/vue3'; +import { Settings } from 'lucide-vue-next'; interface Props { user: User; } -const handleLogout = () => { - router.flushAll(); -}; - defineProps(); @@ -31,11 +27,4 @@ defineProps(); - - - - - Log out - - diff --git a/resources/js/layouts/AuthLayout.vue b/resources/js/layouts/AuthLayout.vue deleted file mode 100644 index 4b09455..0000000 --- a/resources/js/layouts/AuthLayout.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - diff --git a/resources/js/layouts/auth/AuthCardLayout.vue b/resources/js/layouts/auth/AuthCardLayout.vue deleted file mode 100644 index 4d71ee9..0000000 --- a/resources/js/layouts/auth/AuthCardLayout.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - {{ title }} - - {{ description }} - - - - - - - - - - diff --git a/resources/js/layouts/auth/AuthSimpleLayout.vue b/resources/js/layouts/auth/AuthSimpleLayout.vue deleted file mode 100644 index a208182..0000000 --- a/resources/js/layouts/auth/AuthSimpleLayout.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - {{ title }} - - - {{ title }} - {{ description }} - - - - - - - diff --git a/resources/js/layouts/auth/AuthSplitLayout.vue b/resources/js/layouts/auth/AuthSplitLayout.vue deleted file mode 100644 index 13e81bd..0000000 --- a/resources/js/layouts/auth/AuthSplitLayout.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - {{ name }} - - - - “{{ quote.message }}” - - - - - - - - {{ title }} - {{ description }} - - - - - - diff --git a/resources/js/layouts/settings/Layout.vue b/resources/js/layouts/settings/Layout.vue index 0f74bb7..08f68a6 100644 --- a/resources/js/layouts/settings/Layout.vue +++ b/resources/js/layouts/settings/Layout.vue @@ -7,12 +7,8 @@ import { Link, usePage } from '@inertiajs/vue3'; const sidebarNavItems: NavItem[] = [ { - title: 'Profile', - href: '/settings/profile', - }, - { - title: 'Password', - href: '/settings/password', + title: 'API Keys', + href: '/settings/api-keys', }, { title: 'Appearance', @@ -27,7 +23,7 @@ const currentPath = page.props.ziggy?.location ? new URL(page.props.ziggy.locati - +
{{ description }}
- “{{ quote.message }}” - -
“{{ quote.message }}”