header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('system.ai-config.index')); } // AI 설정 (gemini, claude, openai) $aiConfigs = AiConfig::whereIn('provider', AiConfig::AI_PROVIDERS) ->orderBy('provider') ->orderByDesc('is_active') ->orderBy('name') ->get(); // 스토리지 설정 (gcs) $storageConfigs = AiConfig::whereIn('provider', AiConfig::STORAGE_PROVIDERS) ->orderBy('provider') ->orderByDesc('is_active') ->orderBy('name') ->get(); return view('system.ai-config.index', [ 'configs' => $aiConfigs, 'aiConfigs' => $aiConfigs, 'storageConfigs' => $storageConfigs, ]); } /** * AI 설정 저장 */ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:50', 'provider' => 'required|string|in:gemini,claude,openai,gcs,notion', 'api_key' => 'nullable|string|max:255', 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_active' => 'boolean', 'options' => 'nullable|array', 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account', 'options.project_id' => 'nullable|string|max:100', 'options.region' => 'nullable|string|max:50', 'options.service_account_path' => 'nullable|string|max:500', 'options.bucket_name' => 'nullable|string|max:200', 'options.service_account_json' => 'nullable|array', ]); // GCS의 경우 별도 검증 if ($validated['provider'] === 'gcs') { if (empty($validated['options']['bucket_name'])) { return response()->json([ 'ok' => false, 'message' => '버킷 이름을 입력해주세요.', ], 422); } $validated['model'] = '-'; // GCS는 모델 불필요 $validated['api_key'] = 'gcs_service_account'; // DB NOT NULL 제약 } else { // AI 설정: Vertex AI가 아닌 경우 API 키 필수 $authType = $validated['options']['auth_type'] ?? 'api_key'; if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { return response()->json([ 'ok' => false, 'message' => 'API 키를 입력해주세요.', ], 422); } } // 활성화 시 동일 provider의 다른 설정 비활성화 if ($validated['is_active'] ?? false) { AiConfig::where('provider', $validated['provider']) ->update(['is_active' => false]); } $config = AiConfig::create($validated); return response()->json([ 'ok' => true, 'message' => '저장되었습니다.', 'data' => $config, ]); } /** * AI 설정 수정 */ public function update(Request $request, int $id): JsonResponse { $config = AiConfig::findOrFail($id); $validated = $request->validate([ 'name' => 'required|string|max:50', 'provider' => 'required|string|in:gemini,claude,openai,gcs,notion', 'api_key' => 'nullable|string|max:255', 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_active' => 'boolean', 'options' => 'nullable|array', 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account', 'options.project_id' => 'nullable|string|max:100', 'options.region' => 'nullable|string|max:50', 'options.service_account_path' => 'nullable|string|max:500', 'options.bucket_name' => 'nullable|string|max:200', 'options.service_account_json' => 'nullable|array', ]); // GCS의 경우 별도 검증 if ($validated['provider'] === 'gcs') { if (empty($validated['options']['bucket_name'])) { return response()->json([ 'ok' => false, 'message' => '버킷 이름을 입력해주세요.', ], 422); } $validated['model'] = '-'; $validated['api_key'] = 'gcs_service_account'; } else { // AI 설정: Vertex AI가 아닌 경우 API 키 필수 $authType = $validated['options']['auth_type'] ?? 'api_key'; if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { return response()->json([ 'ok' => false, 'message' => 'API 키를 입력해주세요.', ], 422); } } // 활성화 시 동일 provider의 다른 설정 비활성화 if ($validated['is_active'] ?? false) { AiConfig::where('provider', $validated['provider']) ->where('id', '!=', $id) ->update(['is_active' => false]); } $config->update($validated); return response()->json([ 'ok' => true, 'message' => '수정되었습니다.', 'data' => $config->fresh(), ]); } /** * AI 설정 삭제 */ public function destroy(int $id): JsonResponse { $config = AiConfig::findOrFail($id); $config->delete(); return response()->json([ 'ok' => true, 'message' => '삭제되었습니다.', ]); } /** * AI 설정 활성화/비활성화 토글 */ public function toggle(int $id): JsonResponse { $config = AiConfig::findOrFail($id); if (! $config->is_active) { // 활성화 시 동일 provider의 다른 설정 비활성화 AiConfig::where('provider', $config->provider) ->where('id', '!=', $id) ->update(['is_active' => false]); } $config->update(['is_active' => ! $config->is_active]); return response()->json([ 'ok' => true, 'message' => $config->is_active ? '활성화되었습니다.' : '비활성화되었습니다.', 'data' => $config->fresh(), ]); } /** * API 연결 테스트 */ public function test(Request $request): JsonResponse { $validated = $request->validate([ 'provider' => 'required|string|in:gemini,claude,openai,notion', 'api_key' => 'nullable|string', 'model' => 'required|string', 'base_url' => 'nullable|string', 'auth_type' => 'nullable|string|in:api_key,vertex_ai', 'project_id' => 'nullable|string', 'region' => 'nullable|string', 'service_account_path' => 'nullable|string', ]); try { $provider = $validated['provider']; $model = $validated['model']; $authType = $validated['auth_type'] ?? 'api_key'; if ($provider === 'gemini') { if ($authType === 'vertex_ai') { // Vertex AI (서비스 계정) 방식 $result = $this->testGeminiVertexAi( $model, $validated['project_id'] ?? '', $validated['region'] ?? 'us-central1', $validated['service_account_path'] ?? '' ); } else { // API 키 방식 $apiKey = $validated['api_key'] ?? ''; $baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider]; $result = $this->testGemini($baseUrl, $model, $apiKey); } } else { return response()->json([ 'ok' => false, 'error' => '아직 지원하지 않는 provider입니다.', ]); } return response()->json($result); } catch (\Exception $e) { return response()->json([ 'ok' => false, 'error' => $e->getMessage(), ]); } } /** * Gemini API 테스트 (API 키 방식) */ private function testGemini(string $baseUrl, string $model, string $apiKey): array { $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; $response = \Illuminate\Support\Facades\Http::timeout(10)->post($url, [ 'contents' => [ [ 'parts' => [ ['text' => '안녕하세요. 테스트입니다. "OK"라고만 응답해주세요.'], ], ], ], 'generationConfig' => [ 'temperature' => 0, 'maxOutputTokens' => 10, ], ]); if ($response->successful()) { return [ 'ok' => true, 'message' => '연결 테스트 성공', ]; } return [ 'ok' => false, 'error' => 'API 응답 오류: '.$response->status(), ]; } /** * Gemini API 테스트 (Vertex AI 방식) */ private function testGeminiVertexAi(string $model, string $projectId, string $region, string $serviceAccountPath): array { // 필수 파라미터 검증 if (empty($projectId)) { return ['ok' => false, 'error' => '프로젝트 ID가 필요합니다.']; } if (empty($serviceAccountPath)) { return ['ok' => false, 'error' => '서비스 계정 파일 경로가 필요합니다.']; } if (! file_exists($serviceAccountPath)) { return ['ok' => false, 'error' => "서비스 계정 파일을 찾을 수 없습니다: {$serviceAccountPath}"]; } // 서비스 계정 JSON 로드 $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); if (! $serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) { return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.']; } // OAuth 토큰 획득 $accessToken = $this->getVertexAiAccessToken($serviceAccount); if (! $accessToken) { return ['ok' => false, 'error' => 'OAuth 토큰 획득 실패. 서비스 계정 권한을 확인하세요.']; } // Vertex AI 엔드포인트 URL 구성 $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; $response = \Illuminate\Support\Facades\Http::timeout(30) ->withHeaders([ 'Authorization' => 'Bearer '.$accessToken, 'Content-Type' => 'application/json', ]) ->post($url, [ 'contents' => [ [ 'role' => 'user', 'parts' => [ ['text' => '안녕하세요. 테스트입니다. "OK"라고만 응답해주세요.'], ], ], ], 'generationConfig' => [ 'temperature' => 0, 'maxOutputTokens' => 10, ], ]); if ($response->successful()) { return [ 'ok' => true, 'message' => 'Vertex AI 연결 테스트 성공', ]; } // 상세 오류 메시지 추출 $errorBody = $response->json(); $errorMsg = $errorBody['error']['message'] ?? ('HTTP '.$response->status()); return [ 'ok' => false, 'error' => "Vertex AI 오류: {$errorMsg}", ]; } /** * Vertex AI OAuth 토큰 획득 */ private function getVertexAiAccessToken(array $serviceAccount): ?string { $now = time(); $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $jwtClaim = $this->base64UrlEncode(json_encode([ 'iss' => $serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/cloud-platform', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now, ])); $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); if (! $privateKey) { return null; } openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (PHP_VERSION_ID < 80000) { openssl_free_key($privateKey); } $jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature); $response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]); if ($response->successful()) { return $response->json('access_token'); } return null; } /** * GCS 연결 테스트 */ public function testGcs(Request $request): JsonResponse { $validated = $request->validate([ 'bucket_name' => 'required|string', 'service_account_path' => 'nullable|string', 'service_account_json' => 'nullable|array', ]); try { $bucketName = $validated['bucket_name']; $serviceAccount = null; // 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로) if (! empty($validated['service_account_json'])) { $serviceAccount = $validated['service_account_json']; } elseif (! empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) { $serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true); } if (! $serviceAccount) { return response()->json([ 'ok' => false, 'error' => '서비스 계정 정보를 찾을 수 없습니다.', ]); } // OAuth 토큰 획득 $accessToken = $this->getGcsAccessToken($serviceAccount); if (! $accessToken) { return response()->json([ 'ok' => false, 'error' => 'OAuth 토큰 획득 실패', ]); } // 버킷 존재 확인 $response = \Illuminate\Support\Facades\Http::timeout(10) ->withHeaders(['Authorization' => 'Bearer '.$accessToken]) ->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}"); if ($response->successful()) { return response()->json([ 'ok' => true, 'message' => "GCS 연결 성공! 버킷: {$bucketName}", ]); } return response()->json([ 'ok' => false, 'error' => '버킷 접근 실패: '.$response->status(), ]); } catch (\Exception $e) { return response()->json([ 'ok' => false, 'error' => $e->getMessage(), ]); } } /** * GCS OAuth 토큰 획득 */ private function getGcsAccessToken(array $serviceAccount): ?string { $now = time(); $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $jwtClaim = $this->base64UrlEncode(json_encode([ 'iss' => $serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/devstorage.full_control', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now, ])); $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); if (! $privateKey) { return null; } openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (PHP_VERSION_ID < 80000) { openssl_free_key($privateKey); } $jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature); $response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]); if ($response->successful()) { return $response->json('access_token'); } return null; } /** * Base64 URL 인코딩 */ private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } }