Files
sam-manage/resources/views/system/ai-config/index.blade.php
pro 7b7cebaefd fix:AI 설정 Vertex AI 저장 오류 및 모달 동작 개선
- api_key NOT NULL 제약 해결 (Vertex AI 모드에서 플레이스홀더 값 사용)
- 모달 백그라운드 클릭 시 닫히지 않도록 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:14:06 +09:00

587 lines
26 KiB
PHP

@extends('layouts.app')
@section('title', 'AI 설정 관리')
@push('styles')
<style>
.provider-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.provider-gemini { background: #e8f0fe; color: #1a73e8; }
.provider-claude { background: #fef3e8; color: #d97706; }
.provider-openai { background: #e8f8e8; color: #16a34a; }
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-active { background: #dcfce7; color: #16a34a; }
.status-inactive { background: #f3f4f6; color: #6b7280; }
.config-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 16px;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 24px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-overlay.hidden {
display: none !important;
}
</style>
@endpush
@section('content')
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">AI 설정 관리</h1>
<p class="text-sm text-gray-500 mt-1">AI API 모델 설정을 관리합니다</p>
</div>
<button type="button" onclick="openModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition inline-flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
설정 추가
</button>
</div>
<!-- 설정 목록 -->
<div id="config-list">
@forelse($configs as $config)
<div class="config-card" data-id="{{ $config->id }}">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-lg text-gray-800">{{ $config->name }}</h3>
<span class="provider-badge provider-{{ $config->provider }}">
{{ $config->provider_label }}
</span>
<span class="status-badge {{ $config->is_active ? 'status-active' : 'status-inactive' }}">
{{ $config->status_label }}
</span>
</div>
<div class="text-sm text-gray-600 space-y-1">
<p><span class="font-medium">모델:</span> {{ $config->model }}</p>
<p><span class="font-medium">인증:</span> {{ $config->auth_type_label }}</p>
@if($config->isVertexAi())
<p><span class="font-medium">프로젝트:</span> {{ $config->getProjectId() }} ({{ $config->getRegion() }})</p>
@else
<p><span class="font-medium">API :</span> {{ $config->masked_api_key }}</p>
@endif
@if($config->description)
<p><span class="font-medium">설명:</span> {{ $config->description }}</p>
@endif
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" onclick="testConnection({{ $config->id }})" class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
테스트
</button>
<button type="button" onclick="toggleConfig({{ $config->id }})" class="px-3 py-1.5 text-sm {{ $config->is_active ? 'bg-yellow-100 hover:bg-yellow-200 text-yellow-700' : 'bg-green-100 hover:bg-green-200 text-green-700' }} rounded-lg transition">
{{ $config->is_active ? '비활성화' : '활성화' }}
</button>
<button type="button" data-config='@json($config)' onclick="editConfig(this)" class="px-3 py-1.5 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition">
수정
</button>
<button type="button" onclick="deleteConfig({{ $config->id }}, '{{ $config->name }}')" class="px-3 py-1.5 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition">
삭제
</button>
</div>
</div>
</div>
@empty
<div class="text-center py-12 bg-white rounded-lg shadow-sm">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<p class="text-gray-500">등록된 AI 설정이 없습니다.</p>
<p class="text-sm text-gray-400 mt-1">'새 설정 추가' 버튼을 클릭하여 AI API를 등록하세요.</p>
</div>
@endforelse
</div>
<!-- 사용 안내 -->
<div class="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="font-medium text-blue-800 mb-2">사용 안내</h3>
<ul class="text-sm text-blue-700 space-y-1">
<li> Provider(Gemini, Claude, OpenAI)별로 하나의 설정만 활성화할 있습니다.</li>
<li>명함 OCR 기능은 Gemini Vision API를 사용합니다.</li>
<li>API 키는 제공자의 콘솔에서 발급받을 있습니다.</li>
<li>테스트 버튼으로 API 연결 상태를 확인할 있습니다.</li>
</ul>
</div>
</div>
<!-- 추가/수정 모달 -->
<div id="config-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="flex items-center justify-between mb-6">
<h2 id="modal-title" class="text-xl font-bold text-gray-800"> 설정 추가</h2>
<button type="button" onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form id="config-form" class="space-y-4">
<input type="hidden" id="config-id" value="">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Provider <span class="text-red-500">*</span></label>
<select id="config-provider" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="gemini">Google Gemini</option>
<option value="claude">Anthropic Claude</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">설정 이름 <span class="text-red-500">*</span></label>
<input type="text" id="config-name" required placeholder="예: Gemini Production" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 인증 방식 (Gemini만 해당) -->
<div id="auth-type-section" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">인증 방식 <span class="text-red-500">*</span></label>
<select id="config-auth-type" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="api_key">API (Google AI Studio)</option>
<option value="vertex_ai">Vertex AI (서비스 계정)</option>
</select>
<p class="mt-1 text-xs text-gray-500">유료 플랜은 Vertex AI를 선택하세요</p>
</div>
<!-- API 인증 섹션 -->
<div id="api-key-section">
<label class="block text-sm font-medium text-gray-700 mb-1">API <span class="text-red-500">*</span></label>
<input type="password" id="config-api-key" placeholder="API 키 입력" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- Vertex AI 섹션 -->
<div id="vertex-ai-section" class="hidden space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 ID <span class="text-red-500">*</span></label>
<input type="text" id="config-project-id" placeholder="예: my-gcp-project" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">리전</label>
<select id="config-region" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="us-central1">us-central1 (아이오와)</option>
<option value="asia-northeast3">asia-northeast3 (서울)</option>
<option value="asia-northeast1">asia-northeast1 (도쿄)</option>
<option value="europe-west1">europe-west1 (벨기에)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">서비스 계정 파일 경로 <span class="text-red-500">*</span></label>
<input type="text" id="config-service-account-path" placeholder="/var/www/sales/apikey/google_service_account.json" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-xs text-gray-500">Docker 컨테이너 내부 경로로 입력</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">모델 <span class="text-red-500">*</span></label>
<input type="text" id="config-model" required placeholder="예: gemini-2.0-flash" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-xs text-gray-500">기본값: <span id="default-model">gemini-2.0-flash</span></p>
</div>
<div id="base-url-section">
<label class="block text-sm font-medium text-gray-700 mb-1">Base URL (선택)</label>
<input type="url" id="config-base-url" placeholder="비워두면 기본 URL 사용" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">설명 (선택)</label>
<textarea id="config-description" rows="2" placeholder="설정에 대한 설명" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div class="flex items-center">
<input type="checkbox" id="config-is-active" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="config-is-active" class="ml-2 text-sm text-gray-700">활성화 (동일 Provider의 기존 활성 설정은 비활성화됩니다)</label>
</div>
<div class="flex justify-end gap-3 pt-4 border-t">
<button type="button" onclick="closeModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
취소
</button>
<button type="button" onclick="testConnectionFromModal()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition">
연결 테스트
</button>
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
저장
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
(function() {
const csrfToken = '{{ csrf_token() }}';
// 토스트 메시지 함수
function showToast(message, type = 'info') {
// 기존 토스트 제거
const existingToast = document.getElementById('custom-toast');
if (existingToast) {
existingToast.remove();
}
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const toast = document.createElement('div');
toast.id = 'custom-toast';
toast.className = `fixed top-4 right-4 ${colors[type] || colors.info} text-white px-6 py-3 rounded-lg shadow-lg z-[100] transition-opacity duration-300`;
toast.textContent = message;
document.body.appendChild(toast);
// 3초 후 자동 제거
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
const defaultModels = {
gemini: 'gemini-2.0-flash',
claude: 'claude-sonnet-4-20250514',
openai: 'gpt-4o'
};
// 인증 방식에 따른 UI 전환
function toggleAuthTypeUI(provider, authType) {
const authTypeSection = document.getElementById('auth-type-section');
const apiKeySection = document.getElementById('api-key-section');
const vertexAiSection = document.getElementById('vertex-ai-section');
const baseUrlSection = document.getElementById('base-url-section');
// Gemini만 인증 방식 선택 가능
if (provider === 'gemini') {
authTypeSection.classList.remove('hidden');
if (authType === 'vertex_ai') {
apiKeySection.classList.add('hidden');
vertexAiSection.classList.remove('hidden');
baseUrlSection.classList.add('hidden');
document.getElementById('config-api-key').removeAttribute('required');
} else {
apiKeySection.classList.remove('hidden');
vertexAiSection.classList.add('hidden');
baseUrlSection.classList.remove('hidden');
document.getElementById('config-api-key').setAttribute('required', 'required');
}
} else {
authTypeSection.classList.add('hidden');
apiKeySection.classList.remove('hidden');
vertexAiSection.classList.add('hidden');
baseUrlSection.classList.remove('hidden');
document.getElementById('config-api-key').setAttribute('required', 'required');
}
}
// 모달 열기
window.openModal = function(config) {
const modal = document.getElementById('config-modal');
const title = document.getElementById('modal-title');
const form = document.getElementById('config-form');
if (config) {
title.textContent = '설정 수정';
document.getElementById('config-id').value = config.id;
document.getElementById('config-provider').value = config.provider;
document.getElementById('config-name').value = config.name;
document.getElementById('config-api-key').value = config.api_key || '';
document.getElementById('config-model').value = config.model;
document.getElementById('config-base-url').value = config.base_url || '';
document.getElementById('config-description').value = config.description || '';
document.getElementById('config-is-active').checked = config.is_active;
// Vertex AI 옵션 로드
const options = config.options || {};
const authType = options.auth_type || 'api_key';
document.getElementById('config-auth-type').value = authType;
document.getElementById('config-project-id').value = options.project_id || '';
document.getElementById('config-region').value = options.region || 'us-central1';
document.getElementById('config-service-account-path').value = options.service_account_path || '';
toggleAuthTypeUI(config.provider, authType);
} else {
title.textContent = '새 설정 추가';
form.reset();
document.getElementById('config-id').value = '';
document.getElementById('config-auth-type').value = 'api_key';
toggleAuthTypeUI('gemini', 'api_key');
}
modal.classList.remove('hidden');
};
// 모달 닫기
window.closeModal = function() {
document.getElementById('config-modal').classList.add('hidden');
};
// 수정
window.editConfig = function(btn) {
try {
const config = JSON.parse(btn.dataset.config);
window.openModal(config);
} catch (e) {
console.error('Config parse error:', e);
showToast('설정 데이터를 불러올 수 없습니다.', 'error');
}
};
// 토글
window.toggleConfig = async function(id) {
try {
const response = await fetch(`{{ url('system/ai-config') }}/${id}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
});
const result = await response.json();
if (result.ok) {
showToast(result.message, 'success');
location.reload();
} else {
showToast(result.message || '변경 실패', 'error');
}
} catch (error) {
showToast('처리 중 오류가 발생했습니다.', 'error');
}
};
// 삭제
window.deleteConfig = async function(id, name) {
if (!confirm(`'${name}' 설정을 삭제하시겠습니까?`)) {
return;
}
try {
const response = await fetch(`{{ url('system/ai-config') }}/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
});
const result = await response.json();
if (result.ok) {
showToast(result.message, 'success');
location.reload();
} else {
showToast(result.message || '삭제 실패', 'error');
}
} catch (error) {
showToast('삭제 중 오류가 발생했습니다.', 'error');
}
};
// 연결 테스트 (목록에서)
window.testConnection = async function(id) {
showToast('설정 수정 화면에서 테스트해주세요.', 'warning');
};
// 연결 테스트 (모달에서)
window.testConnectionFromModal = async function() {
const data = {
provider: document.getElementById('config-provider').value,
api_key: document.getElementById('config-api-key').value,
model: document.getElementById('config-model').value,
base_url: document.getElementById('config-base-url').value || null
};
if (!data.api_key) {
showToast('API 키를 입력해주세요.', 'error');
return;
}
showToast('연결 테스트 중...', 'info');
try {
const response = await fetch('{{ route("system.ai-config.test") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.ok) {
showToast('연결 테스트 성공!', 'success');
} else {
showToast(result.error || '연결 테스트 실패', 'error');
}
} catch (error) {
showToast('테스트 중 오류가 발생했습니다.', 'error');
}
};
// 폼 제출
async function handleFormSubmit(e) {
e.preventDefault();
const id = document.getElementById('config-id').value;
const provider = document.getElementById('config-provider').value;
const authType = document.getElementById('config-auth-type').value;
const data = {
provider: provider,
name: document.getElementById('config-name').value,
model: document.getElementById('config-model').value,
description: document.getElementById('config-description').value || null,
is_active: document.getElementById('config-is-active').checked,
options: {}
};
// Gemini + Vertex AI인 경우
if (provider === 'gemini' && authType === 'vertex_ai') {
data.api_key = 'vertex_ai_service_account'; // Vertex AI는 서비스 계정 사용 (DB NOT NULL 제약)
data.base_url = '';
data.options = {
auth_type: 'vertex_ai',
project_id: document.getElementById('config-project-id').value,
region: document.getElementById('config-region').value,
service_account_path: document.getElementById('config-service-account-path').value
};
// 필수 값 검증
if (!data.options.project_id || !data.options.service_account_path) {
showToast('프로젝트 ID와 서비스 계정 경로를 입력해주세요.', 'error');
return;
}
} else {
data.api_key = document.getElementById('config-api-key').value;
data.base_url = document.getElementById('config-base-url').value || null;
data.options = { auth_type: 'api_key' };
if (!data.api_key) {
showToast('API 키를 입력해주세요.', 'error');
return;
}
}
try {
const url = id
? `{{ url('system/ai-config') }}/${id}`
: '{{ route("system.ai-config.store") }}';
const method = id ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.ok) {
showToast(result.message, 'success');
window.closeModal();
location.reload();
} else {
showToast(result.message || '저장 실패', 'error');
}
} catch (error) {
showToast('저장 중 오류가 발생했습니다.', 'error');
}
}
// DOM 로드 후 이벤트 리스너 등록
document.addEventListener('DOMContentLoaded', function() {
// 페이지 로드 시 모달 강제 닫기
const modal = document.getElementById('config-modal');
if (modal) {
modal.classList.add('hidden');
}
// Provider 변경 시 기본 모델 업데이트 및 UI 전환
const providerEl = document.getElementById('config-provider');
if (providerEl) {
providerEl.addEventListener('change', function() {
const provider = this.value;
document.getElementById('default-model').textContent = defaultModels[provider];
document.getElementById('config-model').placeholder = '예: ' + defaultModels[provider];
// Gemini가 아니면 API 키 모드로 강제
if (provider !== 'gemini') {
document.getElementById('config-auth-type').value = 'api_key';
}
toggleAuthTypeUI(provider, document.getElementById('config-auth-type').value);
});
}
// 인증 방식 변경 시 UI 전환
const authTypeEl = document.getElementById('config-auth-type');
if (authTypeEl) {
authTypeEl.addEventListener('change', function() {
const provider = document.getElementById('config-provider').value;
toggleAuthTypeUI(provider, this.value);
});
}
// 폼 제출
const formEl = document.getElementById('config-form');
if (formEl) {
formEl.addEventListener('submit', handleFormSubmit);
}
// 모달 외부 클릭 시 닫지 않음 (의도치 않은 닫힘 방지)
// 닫기 버튼이나 취소 버튼으로만 닫을 수 있음
});
})();
</script>
@endpush