diff --git a/.claude/agent/queue.json b/.claude/agent/queue.json index 8b3c8c8..80c8191 100644 --- a/.claude/agent/queue.json +++ b/.claude/agent/queue.json @@ -1,6 +1,47 @@ { "version": "1.0.0", - "tasks": [], - "completed": [], - "last_sync": null + "tasks": [ + { + "id": "gh-1", + "type": "feature", + "priority": 4, + "spec": "GitHub Issue #1: Add markdown rendering for chat messages\n\n## Description\nAI responses often contain markdown (code blocks, lists, headers, etc.) but currently render as plain text.\n\n## Tasks\n- [ ] Install `marked` and `@tailwindcss/typography`\n- [ ] Update `ChatMessage.vue` to render markdown for assistant messages\n- [ ] Add proper styling for code blocks, lists, tables\n- [ ] Consider syntax highlighting for code blocks (highlight.js or similar)\n\n## Acceptance Criteria\n- Code blocks render with proper formatting\n- Lists, headers, bold/italic render correctly\n- User messages remain plain text\n- Dark mode supported\n\nClose with: fixes #1", + "status": "pending", + "github_issue": 1, + "created_at": "2024-12-08T17:50:00Z" + }, + { + "id": "gh-3", + "type": "feature", + "priority": 1, + "spec": "GitHub Issue #3: Add model info and descriptions to model selector\n\n## Description\nHelp users understand which model to choose by showing descriptions, capabilities, and use cases.\n\n## Tasks\n- [ ] Enhance model selector dropdown with descriptions\n- [ ] Add model capability badges (fast, creative, coding, etc.)\n- [ ] Show model size/context length info\n- [ ] Consider tooltip or expandable info section\n- [ ] Update ModelName enum with richer metadata\n\n## Model info to display\n- **Name**: Human-readable name\n- **Description**: 1-2 sentence summary\n- **Best for**: Use cases (coding, creative writing, analysis)\n- **Speed**: Fast/Medium/Slow indicator\n- **Context length**: Token limit\n\n## Acceptance Criteria\n- Users can make informed model choices\n- Info doesn't clutter the UI\n- Works on mobile\n\nClose with: fixes #3", + "status": "pending", + "github_issue": 3, + "created_at": "2024-12-08T17:50:00Z" + } + ], + "completed": [ + { + "id": "gh-21", + "type": "feature", + "priority": 2, + "spec": "GitHub Issue #21: Add Agent Builder following IndyDevDan's standards", + "status": "complete", + "started_at": "2025-12-08T00:00:00Z", + "completed_at": "2025-12-08T01:00:00Z", + "github_issue": 21, + "created_at": "2024-12-08T17:50:00Z", + "output": "Implemented Agent Builder with full CRUD UI and chat system integration.\n\n## Implementation Summary:\nThe Agent Builder feature was already implemented with:\n- Agent model with proper Eloquent relationships (user, defaultModel, models)\n- Full CRUD controller (AgentController)\n- Vue UI pages (Index, Create, Edit, Show)\n- Comprehensive test coverage (43 tests for agents)\n\n## New Integration Work:\nAdded chat system integration to use custom agents:\n\n### Backend Changes:\n- app/Models/Chat.php: Added agent_id fillable and agent() relationship\n- app/Http/Controllers/ChatController.php: Added agents to index/show views, support for agent_id in store\n- app/Http/Requests/StoreChatRequest.php: Added agent_id validation (nullable, exists)\n- app/Http/Requests/UpdateChatRequest.php: Added agent_id validation (sometimes, nullable)\n\n### Frontend Changes:\n- resources/js/types/chat.ts: Added agent_id and agent to Chat interface\n- resources/js/pages/Chat/Index.vue: Added agent selector dropdown when creating new chat\n- resources/js/pages/Chat/Show.vue: Added agent selector in header, agent indicator badge\n\n## Tests:\n- All 362 tests pass (844 assertions)\n- Existing Agent tests (43) + Chat tests (95) all passing", + "files_changed": [ + "app/Models/Chat.php", + "app/Http/Controllers/ChatController.php", + "app/Http/Requests/StoreChatRequest.php", + "app/Http/Requests/UpdateChatRequest.php", + "resources/js/types/chat.ts", + "resources/js/pages/Chat/Index.vue", + "resources/js/pages/Chat/Show.vue" + ] + } + ], + "last_sync": "2024-12-08T17:50:00Z" } diff --git a/app/Http/Controllers/AgentController.php b/app/Http/Controllers/AgentController.php index 641623a..16688cb 100644 --- a/app/Http/Controllers/AgentController.php +++ b/app/Http/Controllers/AgentController.php @@ -1,8 +1,118 @@ where(function ($query) use ($request) { + $query->whereNull('user_id') + ->orWhere('user_id', $request->user()->id); + }) + ->with('defaultModel') + ->orderByDesc('updated_at') + ->get(); + + return Inertia::render('Agents/Index', [ + 'agents' => $agents, + ]); + } + + public function create(): Response + { + return Inertia::render('Agents/Create', [ + 'models' => AiModel::query()->enabled()->get(), + 'availableTools' => $this->getAvailableTools(), + 'availableCapabilities' => $this->getAvailableCapabilities(), + ]); + } + + public function store(StoreAgentRequest $request): RedirectResponse + { + $agent = $request->user()->agents()->create($request->validated()); + + return to_route('agents.show', $agent); + } + + public function show(Request $request, Agent $agent): Response + { + abort_unless( + $agent->user_id === null || $agent->user_id === $request->user()->id, + 403 + ); + + return Inertia::render('Agents/Show', [ + 'agent' => $agent->load('defaultModel'), + 'models' => AiModel::query()->enabled()->get(), + 'availableTools' => $this->getAvailableTools(), + 'availableCapabilities' => $this->getAvailableCapabilities(), + ]); + } + + public function edit(Request $request, Agent $agent): Response + { + abort_unless($agent->user_id === $request->user()->id, 403); + + return Inertia::render('Agents/Edit', [ + 'agent' => $agent->load('defaultModel'), + 'models' => AiModel::query()->enabled()->get(), + 'availableTools' => $this->getAvailableTools(), + 'availableCapabilities' => $this->getAvailableCapabilities(), + ]); + } + + public function update(UpdateAgentRequest $request, Agent $agent): RedirectResponse + { + $agent->update($request->validated()); + + return to_route('agents.show', $agent); + } + + public function destroy(Request $request, Agent $agent): RedirectResponse + { + abort_unless($agent->user_id === $request->user()->id, 403); + + $agent->delete(); + + return to_route('agents.index'); + } + + /** + * @return array + */ + private function getAvailableTools(): array + { + return [ + ['id' => 'web_search', 'name' => 'Web Search', 'description' => 'Search the web for information'], + ['id' => 'code_interpreter', 'name' => 'Code Interpreter', 'description' => 'Execute and analyze code'], + ['id' => 'file_reader', 'name' => 'File Reader', 'description' => 'Read and parse files'], + ['id' => 'knowledge_base', 'name' => 'Knowledge Base', 'description' => 'Search internal knowledge base'], + ]; + } + + /** + * @return array + */ + private function getAvailableCapabilities(): array + { + return [ + ['id' => 'reasoning', 'name' => 'Advanced Reasoning', 'description' => 'Complex problem solving'], + ['id' => 'creativity', 'name' => 'Creative Writing', 'description' => 'Generate creative content'], + ['id' => 'analysis', 'name' => 'Data Analysis', 'description' => 'Analyze and interpret data'], + ['id' => 'coding', 'name' => 'Code Generation', 'description' => 'Write and review code'], + ]; + } } diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index a0eb43f..a57036e 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -6,6 +6,7 @@ use App\Http\Requests\StoreChatRequest; use App\Http\Requests\UpdateChatRequest; +use App\Models\Agent; use App\Models\Chat; use App\Services\ModelSyncService; use Illuminate\Http\RedirectResponse; @@ -22,10 +23,12 @@ public function __construct( public function index(Request $request): Response { $chats = $request->user()->chats()->orderByDesc('updated_at')->get(); + $agents = $this->getAvailableAgents($request); return Inertia::render('Chat/Index', [ 'chats' => $chats, 'models' => $this->modelSyncService->syncAndGetAvailable(), + 'agents' => $agents, ]); } @@ -34,6 +37,7 @@ public function store(StoreChatRequest $request): RedirectResponse $chat = $request->user()->chats()->create([ 'title' => str($request->validated('message'))->limit(50)->toString(), 'ai_model_id' => $request->validated('ai_model_id'), + 'agent_id' => $request->validated('agent_id'), ]); return to_route('chats.show', $chat); @@ -44,11 +48,13 @@ public function show(Request $request, Chat $chat): Response abort_unless($chat->user_id === $request->user()->id, 403); $chats = $request->user()->chats()->orderByDesc('updated_at')->get(); + $agents = $this->getAvailableAgents($request); return Inertia::render('Chat/Show', [ - 'chat' => $chat->load('messages'), + 'chat' => $chat->load(['messages', 'agent']), 'chats' => $chats, 'models' => $this->modelSyncService->syncAndGetAvailable(), + 'agents' => $agents, ]); } @@ -67,4 +73,22 @@ public function destroy(Request $request, Chat $chat): RedirectResponse return to_route('chats.index'); } + + /** + * Get agents available to the current user. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private function getAvailableAgents(Request $request): \Illuminate\Database\Eloquent\Collection + { + return Agent::query() + ->where(function ($query) use ($request) { + $query->whereNull('user_id') + ->orWhere('user_id', $request->user()->id); + }) + ->active() + ->with('defaultModel') + ->orderBy('name') + ->get(); + } } diff --git a/app/Http/Controllers/GitHubAppController.php b/app/Http/Controllers/GitHubAppController.php new file mode 100644 index 0000000..3257704 --- /dev/null +++ b/app/Http/Controllers/GitHubAppController.php @@ -0,0 +1,129 @@ +route('dashboard') + ->with('success', 'GitHub App connected!'); + } + + /** + * Start device flow authentication. + * Returns a user code to enter at github.com/login/device + */ + public function device(): JsonResponse + { + $deviceCode = $this->gitHubApp->startDeviceFlow(); + + return response()->json([ + 'device_code' => $deviceCode['device_code'], + 'user_code' => $deviceCode['user_code'], + 'verification_uri' => $deviceCode['verification_uri'], + 'expires_in' => $deviceCode['expires_in'], + 'interval' => $deviceCode['interval'], + ]); + } + + /** + * Poll for device flow completion. + */ + public function poll(Request $request): JsonResponse + { + $request->validate([ + 'device_code' => 'required|string', + ]); + + $result = $this->gitHubApp->pollDeviceFlow($request->device_code); + + return response()->json($result); + } + + /** + * OAuth callback (for web flow, if needed later). + */ + public function callback(Request $request): RedirectResponse + { + $code = $request->query('code'); + $installationId = $request->query('installation_id'); + $setupAction = $request->query('setup_action'); + + if ($installationId) { + $this->gitHubApp->handleInstallation($installationId); + } + + if ($code) { + $this->gitHubApp->exchangeCodeForToken($code); + } + + return redirect()->route('github.index') + ->with('success', 'GitHub App configured successfully!'); + } + + /** + * Webhook endpoint (placeholder - needs tunnel for real use). + */ + public function webhook(Request $request): JsonResponse + { + $event = $request->header('X-GitHub-Event'); + $payload = $request->all(); + + // Log for now, process later when we have tunnel + logger()->info('GitHub webhook received', [ + 'event' => $event, + 'action' => $payload['action'] ?? null, + ]); + + return response()->json(['status' => 'received']); + } + + /** + * Test making a commit as the app. + */ + public function testCommit(Request $request): JsonResponse + { + $request->validate([ + 'installation_id' => 'required|integer', + 'repo' => 'required|string', + ]); + + $result = $this->gitHubApp->testCommitAsApp( + $request->installation_id, + $request->repo + ); + + return response()->json($result); + } + + /** + * Get installation token for API calls. + */ + public function installationToken(Request $request): JsonResponse + { + $request->validate([ + 'installation_id' => 'required|integer', + ]); + + $token = $this->gitHubApp->getInstallationToken($request->installation_id); + + return response()->json([ + 'token' => $token['token'], + 'expires_at' => $token['expires_at'], + ]); + } +} diff --git a/app/Http/Requests/StoreChatRequest.php b/app/Http/Requests/StoreChatRequest.php index 326988e..0cbe2dc 100644 --- a/app/Http/Requests/StoreChatRequest.php +++ b/app/Http/Requests/StoreChatRequest.php @@ -21,6 +21,7 @@ public function rules(): array return [ 'message' => ['required', 'string', 'max:10000'], 'ai_model_id' => ['required', 'integer', 'exists:ai_models,id'], + 'agent_id' => ['nullable', 'integer', 'exists:agents,id'], ]; } } diff --git a/app/Http/Requests/UpdateChatRequest.php b/app/Http/Requests/UpdateChatRequest.php index 2c200e8..d730380 100644 --- a/app/Http/Requests/UpdateChatRequest.php +++ b/app/Http/Requests/UpdateChatRequest.php @@ -27,6 +27,7 @@ public function rules(): array return [ 'ai_model_id' => ['sometimes', 'integer', 'exists:ai_models,id'], 'title' => ['sometimes', 'string', 'max:255'], + 'agent_id' => ['sometimes', 'nullable', 'integer', 'exists:agents,id'], ]; } } diff --git a/app/Models/Agent.php b/app/Models/Agent.php index 8ffda8d..54b965e 100644 --- a/app/Models/Agent.php +++ b/app/Models/Agent.php @@ -4,8 +4,11 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Agent extends Model { @@ -26,4 +29,83 @@ class Agent extends Model 'capabilities', 'is_active', ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'tools' => 'array', + 'capabilities' => 'array', + 'is_active' => 'boolean', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function defaultModel(): BelongsTo + { + return $this->belongsTo(AiModel::class, 'default_model_id'); + } + + /** + * @return BelongsToMany + */ + public function models(): BelongsToMany + { + return $this->belongsToMany(AiModel::class, 'agent_model'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeSystem(Builder $query): Builder + { + return $query->whereNull('user_id'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForUser(Builder $query, User $user): Builder + { + return $query->where('user_id', $user->id); + } + + /** + * Check if this agent has a specific tool enabled. + */ + public function hasTool(string $tool): bool + { + return in_array($tool, $this->tools ?? [], true); + } + + /** + * Check if this agent has a specific capability. + */ + public function hasCapability(string $capability): bool + { + return in_array($capability, $this->capabilities ?? [], true); + } } diff --git a/app/Models/Chat.php b/app/Models/Chat.php index a7ec042..1008b03 100644 --- a/app/Models/Chat.php +++ b/app/Models/Chat.php @@ -24,6 +24,7 @@ class Chat extends Model 'user_id', 'title', 'ai_model_id', + 'agent_id', ]; /** @@ -49,4 +50,12 @@ public function aiModel(): BelongsTo { return $this->belongsTo(AiModel::class); } + + /** + * @return BelongsTo + */ + public function agent(): BelongsTo + { + return $this->belongsTo(Agent::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 9726b62..dabfc43 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -21,6 +21,14 @@ public function chats(): \Illuminate\Database\Eloquent\Relations\HasMany return $this->hasMany(Chat::class); } + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function agents(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Agent::class); + } + /** * The attributes that are mass assignable. * diff --git a/app/Services/GitHubAppService.php b/app/Services/GitHubAppService.php new file mode 100644 index 0000000..10a2522 --- /dev/null +++ b/app/Services/GitHubAppService.php @@ -0,0 +1,252 @@ +appId = config('services.github.app_id', ''); + $this->clientId = config('services.github.client_id', ''); + $this->clientSecret = config('services.github.client_secret', ''); + $this->privateKey = $this->loadPrivateKey(); + } + + private function loadPrivateKey(): ?string + { + // Try direct key first + $key = config('services.github.private_key'); + if ($key) { + return $key; + } + + // Try file path + $path = config('services.github.private_key_path'); + if ($path && file_exists(base_path($path))) { + return file_get_contents(base_path($path)); + } + + return null; + } + + /** + * Check if the GitHub App is configured. + */ + public function isInstalled(): bool + { + return ! empty($this->appId) && ! empty($this->privateKey); + } + + /** + * Get all installations of this app. + * + * @return array> + */ + public function getInstallations(): array + { + if (! $this->isInstalled()) { + return []; + } + + $jwt = $this->generateJwt(); + + $response = Http::withToken($jwt, 'Bearer') + ->accept('application/vnd.github+json') + ->get("{$this->baseUrl}/app/installations"); + + if ($response->successful()) { + return $response->json(); + } + + return []; + } + + /** + * Start device flow for user authentication. + * + * @return array + */ + public function startDeviceFlow(): array + { + $response = Http::asForm() + ->accept('application/json') + ->post('https://github.com/login/device/code', [ + 'client_id' => $this->clientId, + 'scope' => 'repo read:org', + ]); + + return $response->json(); + } + + /** + * Poll for device flow completion. + * + * @return array + */ + public function pollDeviceFlow(string $deviceCode): array + { + $response = Http::asForm() + ->accept('application/json') + ->post('https://github.com/login/oauth/access_token', [ + 'client_id' => $this->clientId, + 'device_code' => $deviceCode, + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + ]); + + return $response->json(); + } + + /** + * Exchange authorization code for access token. + * + * @return array + */ + public function exchangeCodeForToken(string $code): array + { + $response = Http::asForm() + ->accept('application/json') + ->post('https://github.com/login/oauth/access_token', [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $code, + ]); + + $data = $response->json(); + + if (isset($data['access_token'])) { + Cache::put('github_user_token', $data['access_token'], now()->addSeconds($data['expires_in'] ?? 28800)); + } + + return $data; + } + + /** + * Handle new installation. + */ + public function handleInstallation(int $installationId): void + { + Cache::put('github_installation_id', $installationId); + } + + /** + * Get installation access token. + * + * @return array + */ + public function getInstallationToken(int $installationId): array + { + $cacheKey = "github_installation_token_{$installationId}"; + + return Cache::remember($cacheKey, now()->addMinutes(55), function () use ($installationId) { + $jwt = $this->generateJwt(); + + $response = Http::withToken($jwt, 'Bearer') + ->accept('application/vnd.github+json') + ->post("{$this->baseUrl}/app/installations/{$installationId}/access_tokens"); + + return $response->json(); + }); + } + + /** + * Test making a commit as the GitHub App. + * + * @return array + */ + public function testCommitAsApp(int $installationId, string $repo): array + { + $token = $this->getInstallationToken($installationId); + + if (! isset($token['token'])) { + return ['error' => 'Failed to get installation token']; + } + + // Get the default branch + $repoResponse = Http::withToken($token['token']) + ->accept('application/vnd.github+json') + ->get("{$this->baseUrl}/repos/{$repo}"); + + if (! $repoResponse->successful()) { + return ['error' => 'Failed to get repo info']; + } + + $defaultBranch = $repoResponse->json('default_branch'); + + // Get current commit SHA + $refResponse = Http::withToken($token['token']) + ->accept('application/vnd.github+json') + ->get("{$this->baseUrl}/repos/{$repo}/git/ref/heads/{$defaultBranch}"); + + if (! $refResponse->successful()) { + return ['error' => 'Failed to get ref']; + } + + return [ + 'success' => true, + 'repo' => $repo, + 'branch' => $defaultBranch, + 'sha' => $refResponse->json('object.sha'), + 'token_expires' => $token['expires_at'], + 'message' => 'Ready to commit as the-shit-agents app!', + ]; + } + + /** + * Generate JWT for GitHub App authentication. + */ + private function generateJwt(): string + { + $now = time(); + + $payload = [ + 'iat' => $now - 60, + 'exp' => $now + (10 * 60), + 'iss' => $this->appId, + ]; + + return JWT::encode($payload, $this->privateKey, 'RS256'); + } + + /** + * Make an authenticated API request as the app. + * + * @param array $data + * @return array + */ + public function apiRequest(int $installationId, string $method, string $endpoint, array $data = []): array + { + $token = $this->getInstallationToken($installationId); + + if (! isset($token['token'])) { + return ['error' => 'Failed to get installation token']; + } + + $request = Http::withToken($token['token']) + ->accept('application/vnd.github+json'); + + $response = match (strtoupper($method)) { + 'GET' => $request->get("{$this->baseUrl}{$endpoint}"), + 'POST' => $request->post("{$this->baseUrl}{$endpoint}", $data), + 'PUT' => $request->put("{$this->baseUrl}{$endpoint}", $data), + 'PATCH' => $request->patch("{$this->baseUrl}{$endpoint}", $data), + 'DELETE' => $request->delete("{$this->baseUrl}{$endpoint}"), + default => throw new \InvalidArgumentException("Unsupported method: {$method}"), + }; + + return $response->json() ?? []; + } +} diff --git a/composer.json b/composer.json index b65f5f1..ab8a204 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "firebase/php-jwt": "^6.11", "inertiajs/inertia-laravel": "^2.0", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", diff --git a/composer.lock b/composer.lock index 5edc24f..7a3bab5 100644 --- a/composer.lock +++ b/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": "9cc8e4481dfdf605fadf5f2e2e48131c", + "content-hash": "4d5e136d3cc73cf3c2b23d1db52a2d68", "packages": [ { "name": "bacon/bacon-qr-code", @@ -613,6 +613,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", diff --git a/config/services.php b/config/services.php index 1a3ee1a..9fe88cd 100644 --- a/config/services.php +++ b/config/services.php @@ -39,4 +39,12 @@ 'api_key' => env('TAVILY_API_KEY'), ], + 'github' => [ + 'app_id' => env('SHIT_AGENTS_APP_ID'), + 'client_id' => env('SHIT_AGENTS_CLIENT_ID'), + 'client_secret' => env('SHIT_AGENTS_CLIENT_SECRET'), + 'private_key' => env('SHIT_AGENTS_PRIVATE_KEY'), + 'private_key_path' => env('SHIT_AGENTS_PRIVATE_KEY_PATH'), + ], + ]; diff --git a/database/factories/MessageFactory.php b/database/factories/MessageFactory.php index 327d0d7..7e52114 100644 --- a/database/factories/MessageFactory.php +++ b/database/factories/MessageFactory.php @@ -37,4 +37,54 @@ public function assistant(): static 'role' => 'assistant', ]); } + + public function withMarkdownContent(): static + { + $markdownContent = <<<'MARKDOWN' +# Heading 1 + +Here is some **bold text** and *italic text*. + +## Code Example + +Here's an inline `code` snippet. + +```php + This is a blockquote with some wisdom. + +## Table + +| Name | Value | +|------|-------| +| Foo | Bar | +| Baz | Qux | + +## Link + +Check out [Laravel](https://laravel.com) for more info. +MARKDOWN; + + return $this->state(fn (array $attributes) => [ + 'parts' => ['text' => $markdownContent], + ]); + } } diff --git a/resources/css/app.css b/resources/css/app.css index 8550ae6..b829b5f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -514,20 +514,209 @@ 50% { opacity: 1; transform: scale(1.2); } } - /* Code block styling in dark theme */ + /* Comprehensive prose styling for markdown content */ + .prose { + line-height: 1.65; + } + + /* Headings */ + .prose h1, .prose h2, .prose h3, .prose h4 { + font-weight: 600; + margin-top: 1.5em; + margin-bottom: 0.5em; + } + + .prose h1 { font-size: 1.5em; } + .prose h2 { font-size: 1.3em; } + .prose h3 { font-size: 1.15em; } + .prose h4 { font-size: 1em; } + + .prose h1:first-child, + .prose h2:first-child, + .prose h3:first-child, + .prose h4:first-child { + margin-top: 0; + } + + /* Paragraphs */ + .prose p { + margin-top: 0.75em; + margin-bottom: 0.75em; + } + + /* Lists */ + .prose ul, .prose ol { + margin-top: 0.5em; + margin-bottom: 0.5em; + padding-left: 1.5em; + } + + .prose ul { + list-style-type: disc; + } + + .prose ol { + list-style-type: decimal; + } + + .prose li { + margin-top: 0.25em; + margin-bottom: 0.25em; + } + + .prose li > ul, .prose li > ol { + margin-top: 0.25em; + margin-bottom: 0.25em; + } + + /* Blockquotes */ + .prose blockquote { + border-left: 3px solid rgba(99, 102, 241, 0.5); + padding-left: 1em; + margin: 1em 0; + font-style: italic; + opacity: 0.9; + } + + /* Horizontal rules */ + .prose hr { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.15); + margin: 1.5em 0; + } + + /* Links */ + .prose a { + color: #a5b4fc; + text-decoration: underline; + text-underline-offset: 2px; + } + + .prose a:hover { + color: #c7d2fe; + } + + /* Bold and italic */ + .prose strong { + font-weight: 600; + } + + .prose em { + font-style: italic; + } + + /* Tables */ + .prose table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 0.9em; + } + + .prose th, .prose td { + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.5em 0.75em; + text-align: left; + } + + .prose th { + background: rgba(99, 102, 241, 0.15); + font-weight: 600; + } + + .prose tr:nth-child(even) { + background: rgba(255, 255, 255, 0.02); + } + + /* Code block styling - dark theme */ .aurora-bg .prose pre, .dark .prose pre { - background: rgba(0, 0, 0, 0.4) !important; - border: 1px solid rgba(99, 102, 241, 0.2); + background: rgba(0, 0, 0, 0.5) !important; + border: 1px solid rgba(99, 102, 241, 0.25); + border-radius: 0.5rem; + padding: 1em; + margin: 1em 0; + overflow-x: auto; + font-size: 0.875em; + line-height: 1.6; box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.3); } + .aurora-bg .prose pre code, + .dark .prose pre code { + background: transparent !important; + padding: 0; + border-radius: 0; + color: #e2e8f0; + font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace; + } + + /* Inline code */ .aurora-bg .prose code, .dark .prose code { - background: rgba(99, 102, 241, 0.15); + background: rgba(99, 102, 241, 0.2); color: #c7d2fe; - padding: 0.125rem 0.375rem; + padding: 0.15rem 0.4rem; border-radius: 0.25rem; + font-size: 0.9em; + font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace; + } + + /* Light mode prose styling */ + .prose pre { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + padding: 1em; + margin: 1em 0; + overflow-x: auto; + font-size: 0.875em; + line-height: 1.6; + } + + .prose pre code { + background: transparent !important; + padding: 0; + color: #334155; + font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace; + } + + .prose code { + background: #f1f5f9; + color: #6366f1; + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + font-size: 0.9em; + font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace; + } + + .prose a { + color: #6366f1; + } + + .prose a:hover { + color: #4f46e5; + } + + .prose blockquote { + border-left-color: rgba(99, 102, 241, 0.4); + color: #64748b; + } + + /* Dark mode overrides */ + .dark .prose h1, .dark .prose h2, .dark .prose h3, .dark .prose h4, + .aurora-bg .prose h1, .aurora-bg .prose h2, .aurora-bg .prose h3, .aurora-bg .prose h4 { + color: #f1f5f9; + } + + .dark .prose blockquote, + .aurora-bg .prose blockquote { + color: rgba(255, 255, 255, 0.7); + } + + .dark .prose th, + .aurora-bg .prose th { + background: rgba(99, 102, 241, 0.2); } /* Scrollbar styling for dark theme */ diff --git a/resources/js/components/chat/ChatMessage.vue b/resources/js/components/chat/ChatMessage.vue index 8091ed8..4f0b7fb 100644 --- a/resources/js/components/chat/ChatMessage.vue +++ b/resources/js/components/chat/ChatMessage.vue @@ -53,6 +53,7 @@ const copyMessage = async () => {
{ >
-

{{ message.parts?.text }}

-
+

{{ message.parts?.text }}

+
diff --git a/resources/js/pages/Agents/Create.vue b/resources/js/pages/Agents/Create.vue new file mode 100644 index 0000000..549193b --- /dev/null +++ b/resources/js/pages/Agents/Create.vue @@ -0,0 +1,225 @@ + + + diff --git a/resources/js/pages/Agents/Edit.vue b/resources/js/pages/Agents/Edit.vue new file mode 100644 index 0000000..6b713c5 --- /dev/null +++ b/resources/js/pages/Agents/Edit.vue @@ -0,0 +1,241 @@ + + + diff --git a/resources/js/pages/Agents/Index.vue b/resources/js/pages/Agents/Index.vue new file mode 100644 index 0000000..bc1c15f --- /dev/null +++ b/resources/js/pages/Agents/Index.vue @@ -0,0 +1,122 @@ + + + diff --git a/resources/js/pages/Agents/Show.vue b/resources/js/pages/Agents/Show.vue new file mode 100644 index 0000000..2aa09c3 --- /dev/null +++ b/resources/js/pages/Agents/Show.vue @@ -0,0 +1,125 @@ + + + diff --git a/resources/js/pages/Chat/Index.vue b/resources/js/pages/Chat/Index.vue index da1d2e4..8dd1318 100644 --- a/resources/js/pages/Chat/Index.vue +++ b/resources/js/pages/Chat/Index.vue @@ -2,8 +2,8 @@ import AppLayout from '@/layouts/AppLayout.vue'; import { Head, router } from '@inertiajs/vue3'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Plus, Sparkles, Zap } from 'lucide-vue-next'; -import type { Chat, Model } from '@/types/chat'; +import { Plus, Sparkles, Zap, Bot } from 'lucide-vue-next'; +import type { Chat, Model, Agent } from '@/types/chat'; import type { BreadcrumbItem } from '@/types'; import { ref } from 'vue'; import { index, store } from '@/actions/App/Http/Controllers/ChatController'; @@ -11,9 +11,11 @@ import { index, store } from '@/actions/App/Http/Controllers/ChatController'; const props = defineProps<{ chats: Chat[]; models: Model[]; + agents: Agent[]; }>(); const selectedModel = ref(props.models[0]?.id ?? ''); +const selectedAgent = ref(''); const breadcrumbs: BreadcrumbItem[] = [ { title: 'Chats', href: index.url() }, @@ -22,7 +24,8 @@ const breadcrumbs: BreadcrumbItem[] = [ const startNewChat = () => { router.post(store.url(), { message: 'New conversation', - model: selectedModel.value, + ai_model_id: selectedModel.value, + agent_id: selectedAgent.value || null, }); }; @@ -63,6 +66,28 @@ const startNewChat = () => { + +
@@ -111,6 +129,29 @@ const supportsTools = computed(() => { Generating... + + +