feat: [additional] Notion 검색 기능 추가
- NotionService: Notion API 검색 + Gemini AI 답변 - AiConfig에 notion provider 추가 - 추가기능 > Notion 검색 채팅 UI
This commit is contained in:
51
app/Http/Controllers/Additional/NotionSearchController.php
Normal file
51
app/Http/Controllers/Additional/NotionSearchController.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Additional;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\NotionService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class NotionSearchController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Notion 검색 페이지 (채팅 UI)
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View|Response
|
||||||
|
{
|
||||||
|
if ($request->header('HX-Request')) {
|
||||||
|
return response('', 200)->header('HX-Redirect', route('additional.notion-search.index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('additional.notion-search.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX 검색 API
|
||||||
|
*/
|
||||||
|
public function search(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'message' => 'required|string|max:1000',
|
||||||
|
'history' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service = new NotionService;
|
||||||
|
$result = $service->searchWithAi(
|
||||||
|
$validated['message'],
|
||||||
|
$validated['history'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'reply' => $e->getMessage(),
|
||||||
|
'debug' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ public function store(Request $request): JsonResponse
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'provider' => 'required|string|in:gemini,claude,openai,gcs',
|
'provider' => 'required|string|in:gemini,claude,openai,gcs,notion',
|
||||||
'api_key' => 'nullable|string|max:255',
|
'api_key' => 'nullable|string|max:255',
|
||||||
'model' => 'nullable|string|max:100',
|
'model' => 'nullable|string|max:100',
|
||||||
'base_url' => 'nullable|string|max:255',
|
'base_url' => 'nullable|string|max:255',
|
||||||
@@ -108,7 +108,7 @@ public function update(Request $request, int $id): JsonResponse
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'provider' => 'required|string|in:gemini,claude,openai,gcs',
|
'provider' => 'required|string|in:gemini,claude,openai,gcs,notion',
|
||||||
'api_key' => 'nullable|string|max:255',
|
'api_key' => 'nullable|string|max:255',
|
||||||
'model' => 'nullable|string|max:100',
|
'model' => 'nullable|string|max:100',
|
||||||
'base_url' => 'nullable|string|max:255',
|
'base_url' => 'nullable|string|max:255',
|
||||||
@@ -203,7 +203,7 @@ public function toggle(int $id): JsonResponse
|
|||||||
public function test(Request $request): JsonResponse
|
public function test(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'provider' => 'required|string|in:gemini,claude,openai',
|
'provider' => 'required|string|in:gemini,claude,openai,notion',
|
||||||
'api_key' => 'nullable|string',
|
'api_key' => 'nullable|string',
|
||||||
'model' => 'required|string',
|
'model' => 'required|string',
|
||||||
'base_url' => 'nullable|string',
|
'base_url' => 'nullable|string',
|
||||||
@@ -279,7 +279,7 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => 'API 응답 오류: ' . $response->status(),
|
'error' => 'API 응답 오류: '.$response->status(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,19 +297,19 @@ private function testGeminiVertexAi(string $model, string $projectId, string $re
|
|||||||
return ['ok' => false, 'error' => '서비스 계정 파일 경로가 필요합니다.'];
|
return ['ok' => false, 'error' => '서비스 계정 파일 경로가 필요합니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file_exists($serviceAccountPath)) {
|
if (! file_exists($serviceAccountPath)) {
|
||||||
return ['ok' => false, 'error' => "서비스 계정 파일을 찾을 수 없습니다: {$serviceAccountPath}"];
|
return ['ok' => false, 'error' => "서비스 계정 파일을 찾을 수 없습니다: {$serviceAccountPath}"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서비스 계정 JSON 로드
|
// 서비스 계정 JSON 로드
|
||||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||||
if (!$serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) {
|
if (! $serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) {
|
||||||
return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.'];
|
return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth 토큰 획득
|
// OAuth 토큰 획득
|
||||||
$accessToken = $this->getVertexAiAccessToken($serviceAccount);
|
$accessToken = $this->getVertexAiAccessToken($serviceAccount);
|
||||||
if (!$accessToken) {
|
if (! $accessToken) {
|
||||||
return ['ok' => false, 'error' => 'OAuth 토큰 획득 실패. 서비스 계정 권한을 확인하세요.'];
|
return ['ok' => false, 'error' => 'OAuth 토큰 획득 실패. 서비스 계정 권한을 확인하세요.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ private function testGeminiVertexAi(string $model, string $projectId, string $re
|
|||||||
|
|
||||||
$response = \Illuminate\Support\Facades\Http::timeout(30)
|
$response = \Illuminate\Support\Facades\Http::timeout(30)
|
||||||
->withHeaders([
|
->withHeaders([
|
||||||
'Authorization' => 'Bearer ' . $accessToken,
|
'Authorization' => 'Bearer '.$accessToken,
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
])
|
])
|
||||||
->post($url, [
|
->post($url, [
|
||||||
@@ -345,7 +345,7 @@ private function testGeminiVertexAi(string $model, string $projectId, string $re
|
|||||||
|
|
||||||
// 상세 오류 메시지 추출
|
// 상세 오류 메시지 추출
|
||||||
$errorBody = $response->json();
|
$errorBody = $response->json();
|
||||||
$errorMsg = $errorBody['error']['message'] ?? ('HTTP ' . $response->status());
|
$errorMsg = $errorBody['error']['message'] ?? ('HTTP '.$response->status());
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
@@ -369,16 +369,16 @@ private function getVertexAiAccessToken(array $serviceAccount): ?string
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||||
if (!$privateKey) {
|
if (! $privateKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||||
if (PHP_VERSION_ID < 80000) {
|
if (PHP_VERSION_ID < 80000) {
|
||||||
openssl_free_key($privateKey);
|
openssl_free_key($privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
$jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature);
|
||||||
|
|
||||||
$response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
$response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
@@ -408,13 +408,13 @@ public function testGcs(Request $request): JsonResponse
|
|||||||
$serviceAccount = null;
|
$serviceAccount = null;
|
||||||
|
|
||||||
// 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로)
|
// 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로)
|
||||||
if (!empty($validated['service_account_json'])) {
|
if (! empty($validated['service_account_json'])) {
|
||||||
$serviceAccount = $validated['service_account_json'];
|
$serviceAccount = $validated['service_account_json'];
|
||||||
} elseif (!empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) {
|
} elseif (! empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) {
|
||||||
$serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true);
|
$serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$serviceAccount) {
|
if (! $serviceAccount) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => '서비스 계정 정보를 찾을 수 없습니다.',
|
'error' => '서비스 계정 정보를 찾을 수 없습니다.',
|
||||||
@@ -423,7 +423,7 @@ public function testGcs(Request $request): JsonResponse
|
|||||||
|
|
||||||
// OAuth 토큰 획득
|
// OAuth 토큰 획득
|
||||||
$accessToken = $this->getGcsAccessToken($serviceAccount);
|
$accessToken = $this->getGcsAccessToken($serviceAccount);
|
||||||
if (!$accessToken) {
|
if (! $accessToken) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => 'OAuth 토큰 획득 실패',
|
'error' => 'OAuth 토큰 획득 실패',
|
||||||
@@ -432,7 +432,7 @@ public function testGcs(Request $request): JsonResponse
|
|||||||
|
|
||||||
// 버킷 존재 확인
|
// 버킷 존재 확인
|
||||||
$response = \Illuminate\Support\Facades\Http::timeout(10)
|
$response = \Illuminate\Support\Facades\Http::timeout(10)
|
||||||
->withHeaders(['Authorization' => 'Bearer ' . $accessToken])
|
->withHeaders(['Authorization' => 'Bearer '.$accessToken])
|
||||||
->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}");
|
->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}");
|
||||||
|
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
@@ -444,7 +444,7 @@ public function testGcs(Request $request): JsonResponse
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => '버킷 접근 실패: ' . $response->status(),
|
'error' => '버킷 접근 실패: '.$response->status(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -467,24 +467,24 @@ private function getGcsAccessToken(array $serviceAccount): ?string
|
|||||||
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
|
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
|
||||||
'aud' => 'https://oauth2.googleapis.com/token',
|
'aud' => 'https://oauth2.googleapis.com/token',
|
||||||
'exp' => $now + 3600,
|
'exp' => $now + 3600,
|
||||||
'iat' => $now
|
'iat' => $now,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||||
if (!$privateKey) {
|
if (! $privateKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||||
if (PHP_VERSION_ID < 80000) {
|
if (PHP_VERSION_ID < 80000) {
|
||||||
openssl_free_key($privateKey);
|
openssl_free_key($privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
$jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature);
|
||||||
|
|
||||||
$response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
$response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
'assertion' => $jwt
|
'assertion' => $jwt,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class AiConfig extends Model
|
|||||||
'claude' => 'https://api.anthropic.com/v1',
|
'claude' => 'https://api.anthropic.com/v1',
|
||||||
'openai' => 'https://api.openai.com/v1',
|
'openai' => 'https://api.openai.com/v1',
|
||||||
'gcs' => 'https://storage.googleapis.com',
|
'gcs' => 'https://storage.googleapis.com',
|
||||||
|
'notion' => 'https://api.notion.com/v1',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,12 +63,13 @@ class AiConfig extends Model
|
|||||||
'claude' => 'claude-sonnet-4-20250514',
|
'claude' => 'claude-sonnet-4-20250514',
|
||||||
'openai' => 'gpt-4o',
|
'openai' => 'gpt-4o',
|
||||||
'gcs' => '-',
|
'gcs' => '-',
|
||||||
|
'notion' => '2025-09-03',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI Provider 목록 (GCS 제외)
|
* AI Provider 목록 (GCS 제외)
|
||||||
*/
|
*/
|
||||||
public const AI_PROVIDERS = ['gemini', 'claude', 'openai'];
|
public const AI_PROVIDERS = ['gemini', 'claude', 'openai', 'notion'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 스토리지 Provider 목록
|
* 스토리지 Provider 목록
|
||||||
@@ -94,6 +96,16 @@ public static function getActiveClaude(): ?self
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 Notion 설정 조회
|
||||||
|
*/
|
||||||
|
public static function getActiveNotion(): ?self
|
||||||
|
{
|
||||||
|
return self::where('provider', 'notion')
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider별 활성 설정 조회
|
* Provider별 활성 설정 조회
|
||||||
*/
|
*/
|
||||||
@@ -122,6 +134,7 @@ public function getProviderLabelAttribute(): string
|
|||||||
'claude' => 'Anthropic Claude',
|
'claude' => 'Anthropic Claude',
|
||||||
'openai' => 'OpenAI',
|
'openai' => 'OpenAI',
|
||||||
'gcs' => 'Google Cloud Storage',
|
'gcs' => 'Google Cloud Storage',
|
||||||
|
'notion' => 'Notion',
|
||||||
default => $this->provider,
|
default => $this->provider,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -193,7 +206,7 @@ public function getMaskedApiKeyAttribute(): string
|
|||||||
return $this->api_key;
|
return $this->api_key;
|
||||||
}
|
}
|
||||||
|
|
||||||
return substr($this->api_key, 0, 8) . str_repeat('*', 8) . '...';
|
return substr($this->api_key, 0, 8).str_repeat('*', 8).'...';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,6 +249,7 @@ public function getAuthTypeLabelAttribute(): string
|
|||||||
if ($this->isVertexAi()) {
|
if ($this->isVertexAi()) {
|
||||||
return 'Vertex AI (서비스 계정)';
|
return 'Vertex AI (서비스 계정)';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'API 키';
|
return 'API 키';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
281
app/Services/NotionService.php
Normal file
281
app/Services/NotionService.php
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\System\AiConfig;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class NotionService
|
||||||
|
{
|
||||||
|
private string $apiKey;
|
||||||
|
|
||||||
|
private string $version;
|
||||||
|
|
||||||
|
private string $baseUrl;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$config = AiConfig::getActiveNotion();
|
||||||
|
if (! $config) {
|
||||||
|
throw new \RuntimeException('Notion API 설정이 없거나 비활성화 상태입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKey = $config->api_key;
|
||||||
|
$this->version = $config->model ?: '2025-09-03';
|
||||||
|
$this->baseUrl = $config->base_url ?: 'https://api.notion.com/v1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notion 검색 API 호출
|
||||||
|
*/
|
||||||
|
public function search(string $query, int $limit = 3): ?array
|
||||||
|
{
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->apiKey,
|
||||||
|
'Notion-Version' => $this->version,
|
||||||
|
])->timeout(15)->post($this->baseUrl.'/search', [
|
||||||
|
'query' => $query,
|
||||||
|
'page_size' => $limit,
|
||||||
|
'filter' => [
|
||||||
|
'value' => 'page',
|
||||||
|
'property' => 'object',
|
||||||
|
],
|
||||||
|
'sort' => [
|
||||||
|
'direction' => 'descending',
|
||||||
|
'timestamp' => 'last_edited_time',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Notion API 검색 오류', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 블록 내용 추출
|
||||||
|
*/
|
||||||
|
public function getPageContent(string $pageId): string
|
||||||
|
{
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->apiKey,
|
||||||
|
'Notion-Version' => $this->version,
|
||||||
|
])->timeout(15)->get($this->baseUrl."/blocks/{$pageId}/children");
|
||||||
|
|
||||||
|
if ($response->failed() || ! $response->json('results')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = '';
|
||||||
|
$maxLength = 1500;
|
||||||
|
|
||||||
|
foreach ($response->json('results') as $block) {
|
||||||
|
if (strlen($content) >= $maxLength) {
|
||||||
|
$content .= '...(내용 생략됨)...';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $block['type'] ?? '';
|
||||||
|
if (isset($block[$type]['rich_text'])) {
|
||||||
|
foreach ($block[$type]['rich_text'] as $text) {
|
||||||
|
$content .= $text['plain_text'] ?? '';
|
||||||
|
}
|
||||||
|
$content .= "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini 쿼리 정제 → Notion 검색 → Gemini 답변 (통합)
|
||||||
|
*/
|
||||||
|
public function searchWithAi(string $userMessage, array $history = []): array
|
||||||
|
{
|
||||||
|
$gemini = AiConfig::getActiveGemini();
|
||||||
|
if (! $gemini) {
|
||||||
|
return [
|
||||||
|
'reply' => 'Gemini AI 설정이 없거나 비활성화 상태입니다.',
|
||||||
|
'debug' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$geminiApiKey = $gemini->api_key;
|
||||||
|
$geminiModel = $gemini->model ?: 'gemini-2.0-flash';
|
||||||
|
$geminiBaseUrl = $gemini->base_url ?: 'https://generativelanguage.googleapis.com/v1beta';
|
||||||
|
|
||||||
|
// 대화 이력 텍스트 변환
|
||||||
|
$historyText = $this->buildHistoryText($history);
|
||||||
|
|
||||||
|
// 1. 검색어 정제
|
||||||
|
$refinedQuery = $this->refineQuery($userMessage, $historyText, $geminiBaseUrl, $geminiModel, $geminiApiKey);
|
||||||
|
|
||||||
|
// 2. Notion 검색 및 컨텍스트 확보
|
||||||
|
$searchResults = $this->search($refinedQuery);
|
||||||
|
$context = $this->buildContext($searchResults);
|
||||||
|
|
||||||
|
// 3. Gemini AI 답변 생성
|
||||||
|
$systemInstruction = $this->buildSystemInstruction($historyText, $context);
|
||||||
|
|
||||||
|
$response = Http::timeout(30)->post(
|
||||||
|
"{$geminiBaseUrl}/models/{$geminiModel}:generateContent?key={$geminiApiKey}",
|
||||||
|
[
|
||||||
|
'contents' => [
|
||||||
|
['parts' => [['text' => $userMessage]]],
|
||||||
|
],
|
||||||
|
'systemInstruction' => [
|
||||||
|
'parts' => [['text' => $systemInstruction]],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Gemini API 오류', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'reply' => '오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
'debug' => ['refinedQuery' => $refinedQuery],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $response->json('candidates.0.content.parts.0.text')
|
||||||
|
?? '죄송합니다. 답변을 생성하지 못했습니다.';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'reply' => $reply,
|
||||||
|
'debug' => [
|
||||||
|
'refinedQuery' => $refinedQuery,
|
||||||
|
'context' => $context,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini로 검색어 정제
|
||||||
|
*/
|
||||||
|
private function refineQuery(string $userMessage, string $historyText, string $baseUrl, string $model, string $apiKey): string
|
||||||
|
{
|
||||||
|
$systemInstruction = "You are a detailed search query generator for a Notion database.
|
||||||
|
Analyze the [Current Question] and [Conversation History] (if any).
|
||||||
|
1. Correct any typos (e.g., '프론트엔디' -> '프론트엔드').
|
||||||
|
2. Identify the core topic or entities.
|
||||||
|
3. IGNORE standard greetings (e.g., 'Hello', '운영자 문서 탐색').
|
||||||
|
4. Convert the intent into a precise search query likely to match Notion page titles or content.
|
||||||
|
RETURN ONLY THE SEARCH QUERY STRING. Do not explain.";
|
||||||
|
|
||||||
|
$response = Http::timeout(15)->post(
|
||||||
|
"{$baseUrl}/models/{$model}:generateContent?key={$apiKey}",
|
||||||
|
[
|
||||||
|
'contents' => [
|
||||||
|
['parts' => [['text' => "Conversation History:\n{$historyText}\n\nCurrent Question: {$userMessage}"]]],
|
||||||
|
],
|
||||||
|
'systemInstruction' => [
|
||||||
|
'parts' => [['text' => $systemInstruction]],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$refined = $response->json('candidates.0.content.parts.0.text');
|
||||||
|
if ($refined) {
|
||||||
|
return trim($refined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대화 이력을 텍스트로 변환
|
||||||
|
*/
|
||||||
|
private function buildHistoryText(array $history): string
|
||||||
|
{
|
||||||
|
$text = '';
|
||||||
|
foreach ($history as $msg) {
|
||||||
|
$role = ($msg['role'] ?? '') === 'user' ? 'User' : 'Assistant';
|
||||||
|
$parts = $msg['parts'] ?? [];
|
||||||
|
$msgText = is_array($parts) && isset($parts[0]['text']) ? $parts[0]['text'] : (is_string($parts) ? $parts : '');
|
||||||
|
$text .= "{$role}: {$msgText}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색 결과에서 컨텍스트 구성
|
||||||
|
*/
|
||||||
|
private function buildContext(?array $searchResults): string
|
||||||
|
{
|
||||||
|
if (! $searchResults || empty($searchResults['results'])) {
|
||||||
|
return '관련된 내부 문서를 찾을 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = '';
|
||||||
|
foreach ($searchResults['results'] as $page) {
|
||||||
|
$title = $this->extractPageTitle($page);
|
||||||
|
$pageContent = $this->getPageContent($page['id']);
|
||||||
|
$url = $page['url'] ?? '';
|
||||||
|
$context .= "문서 제목: [{$title}]\nURL: {$url}\n내용:\n{$pageContent}\n---\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context ?: '관련된 내부 문서를 찾을 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지에서 제목 추출
|
||||||
|
*/
|
||||||
|
private function extractPageTitle(array $page): string
|
||||||
|
{
|
||||||
|
if (isset($page['properties']['Name']['title'][0]['plain_text'])) {
|
||||||
|
return $page['properties']['Name']['title'][0]['plain_text'];
|
||||||
|
}
|
||||||
|
if (isset($page['properties']['title']['title'][0]['plain_text'])) {
|
||||||
|
return $page['properties']['title']['title'][0]['plain_text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 title 속성 탐색
|
||||||
|
foreach ($page['properties'] ?? [] as $prop) {
|
||||||
|
if (isset($prop['title'][0]['plain_text'])) {
|
||||||
|
return $prop['title'][0]['plain_text'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '제목 없음';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시스템 인스트럭션 구성
|
||||||
|
*/
|
||||||
|
private function buildSystemInstruction(string $historyText, string $context): string
|
||||||
|
{
|
||||||
|
$instruction = "You are a helpful, friendly, and professional customer support agent for 'codebridge-x.com'. Your tone should be polite and efficient, similar to Korean customer service standards. Use the provided [Context] to answer the user's question. If the context doesn't contain the answer, say you don't have that information in the internal documents. Reply in Korean.\n";
|
||||||
|
|
||||||
|
if (! empty($historyText)) {
|
||||||
|
$instruction .= "\n[Conversation History]\n".$historyText;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instruction .= "\nIMPORTANT: Even if you cannot find the direct answer in the Context, you MUST list the documents provided in the Context as '관련 문서' at the bottom.
|
||||||
|
|
||||||
|
If the document content is long or partial (ends with '...'), summarize the key points available and encourage the user to click the link for full details.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
[Answer / Summary]
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
- [문서 제목](URL)
|
||||||
|
|
||||||
|
[Context]\n".$context;
|
||||||
|
|
||||||
|
return $instruction;
|
||||||
|
}
|
||||||
|
}
|
||||||
214
resources/views/additional/notion-search/index.blade.php
Normal file
214
resources/views/additional/notion-search/index.blade.php
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Notion 검색')
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
.ns-wrap { max-width: 800px; margin: 0 auto; padding: 24px 16px; height: calc(100vh - 64px); display: flex; flex-direction: column; }
|
||||||
|
.ns-header { text-align: center; margin-bottom: 16px; flex-shrink: 0; }
|
||||||
|
.ns-header h1 { font-size: 1.25rem; font-weight: 700; color: #1e293b; }
|
||||||
|
.ns-header p { color: #64748b; font-size: 0.85rem; margin-top: 4px; }
|
||||||
|
.ns-chat { flex: 1; display: flex; flex-direction: column; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; min-height: 0; }
|
||||||
|
.ns-messages { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
.ns-messages::-webkit-scrollbar { width: 4px; }
|
||||||
|
.ns-messages::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; }
|
||||||
|
.ns-input-area { flex-shrink: 0; padding: 12px 16px; border-top: 1px solid #e2e8f0; background: #fff; }
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
<div id="notion-search-root"></div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
@include('partials.react-cdn')
|
||||||
|
@verbatim
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState, useEffect, useRef } = React;
|
||||||
|
|
||||||
|
const Sender = { USER: 'user', BOT: 'bot' };
|
||||||
|
|
||||||
|
/* ── API ── */
|
||||||
|
const searchNotion = async (message, history = []) => {
|
||||||
|
try {
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||||
|
const res = await fetch('/additional/notion-search/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message, history }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Network error');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.debug) {
|
||||||
|
console.group('Notion Search Debug');
|
||||||
|
console.log('Refined Query:', data.debug.refinedQuery);
|
||||||
|
console.log('Context:', data.debug.context);
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
return data.reply || '응답을 받을 수 없습니다.';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Search error:', e);
|
||||||
|
return '오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 마크다운 링크 파싱 ── */
|
||||||
|
const parseLinks = (text, isUser) => {
|
||||||
|
const parts = [];
|
||||||
|
let last = 0;
|
||||||
|
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
if (m.index > last) parts.push(text.substring(last, m.index));
|
||||||
|
parts.push(
|
||||||
|
<a key={last} href={m[2]} target="_blank" rel="noopener noreferrer"
|
||||||
|
className={`underline font-medium break-all ${isUser ? 'text-blue-200 hover:text-white' : 'text-blue-600 hover:text-blue-800'}`}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
{m[1]}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
last = re.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < text.length) parts.push(text.substring(last));
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 봇 아바타 ── */
|
||||||
|
const BotAvatar = () => (
|
||||||
|
<div className="shrink-0 rounded-full flex items-center justify-center text-white font-bold text-xs shadow-sm"
|
||||||
|
style={{ width: 32, height: 32, background: 'linear-gradient(135deg, #3b82f6, #4f46e5)' }}>
|
||||||
|
N
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── 메시지 버블 ── */
|
||||||
|
const MessageBubble = ({ msg }) => {
|
||||||
|
const isUser = msg.sender === Sender.USER;
|
||||||
|
return (
|
||||||
|
<div className={`flex mb-3 ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
{!isUser && <div className="mr-2 mt-1"><BotAvatar /></div>}
|
||||||
|
<div className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm whitespace-pre-wrap ${
|
||||||
|
isUser
|
||||||
|
? 'bg-blue-600 text-white rounded-tr-sm'
|
||||||
|
: 'bg-gray-50 text-gray-800 border border-gray-200 rounded-tl-sm'
|
||||||
|
}`}>
|
||||||
|
{parseLinks(msg.text, isUser)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 로딩 인디케이터 ── */
|
||||||
|
const LoadingBubble = () => (
|
||||||
|
<div className="flex justify-start mb-3">
|
||||||
|
<div className="mr-2 mt-1"><BotAvatar /></div>
|
||||||
|
<div className="bg-gray-50 text-gray-400 border border-gray-200 rounded-2xl rounded-tl-sm px-4 py-3 text-sm shadow-sm flex items-center gap-1">
|
||||||
|
<span className="inline-block animate-pulse">답변 작성 중</span>
|
||||||
|
<span className="inline-flex gap-0.5">
|
||||||
|
<span className="w-1 h-1 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
|
||||||
|
<span className="w-1 h-1 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
|
||||||
|
<span className="w-1 h-1 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── 메인 앱 ── */
|
||||||
|
const NotionSearchApp = () => {
|
||||||
|
const [messages, setMessages] = useState([
|
||||||
|
{ id: 'welcome', text: 'Notion 문서를 검색합니다. 궁금한 내용을 입력하세요!', sender: Sender.BOT, ts: new Date() },
|
||||||
|
]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const endRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, loading]);
|
||||||
|
|
||||||
|
const handleSend = async (e) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || loading) return;
|
||||||
|
|
||||||
|
const userMsg = { id: Date.now().toString(), text, sender: Sender.USER, ts: new Date() };
|
||||||
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = messages.slice(-10).map(m => ({
|
||||||
|
role: m.sender === Sender.USER ? 'user' : 'model',
|
||||||
|
parts: [{ text: m.text }],
|
||||||
|
}));
|
||||||
|
const reply = await searchNotion(text, history);
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
text: reply,
|
||||||
|
sender: Sender.BOT,
|
||||||
|
ts: new Date(),
|
||||||
|
}]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ns-wrap">
|
||||||
|
<div className="ns-header">
|
||||||
|
<h1>Notion 검색</h1>
|
||||||
|
<p>Notion 문서에서 검색하고 AI가 답변합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ns-chat">
|
||||||
|
{/* 메시지 영역 */}
|
||||||
|
<div className="ns-messages">
|
||||||
|
{messages.map(msg => <MessageBubble key={msg.id} msg={msg} />)}
|
||||||
|
{loading && <LoadingBubble />}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 영역 */}
|
||||||
|
<div className="ns-input-area">
|
||||||
|
<form onSubmit={handleSend} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-full text-sm text-gray-700 placeholder-gray-400 bg-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="검색어를 입력하세요..."
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || loading}
|
||||||
|
className={`shrink-0 px-4 py-2.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
!input.trim() || loading
|
||||||
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전송
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('notion-search-root'));
|
||||||
|
root.render(<NotionSearchApp />);
|
||||||
|
</script>
|
||||||
|
@endverbatim
|
||||||
|
@endpush
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Additional\KioskController;
|
||||||
|
use App\Http\Controllers\Additional\NotionSearchController;
|
||||||
use App\Http\Controllers\Api\BusinessCardOcrController;
|
use App\Http\Controllers\Api\BusinessCardOcrController;
|
||||||
use App\Http\Controllers\ApiLogController;
|
use App\Http\Controllers\ApiLogController;
|
||||||
use App\Http\Controllers\AppVersionController;
|
use App\Http\Controllers\AppVersionController;
|
||||||
use App\Http\Controllers\ESign\EsignApiController;
|
|
||||||
use App\Http\Controllers\ESign\EsignController;
|
|
||||||
use App\Http\Controllers\ESign\EsignPublicController;
|
|
||||||
use App\Http\Controllers\ArchivedRecordController;
|
use App\Http\Controllers\ArchivedRecordController;
|
||||||
use App\Http\Controllers\AuditLogController;
|
use App\Http\Controllers\AuditLogController;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
@@ -21,25 +20,27 @@
|
|||||||
use App\Http\Controllers\DevTools\FlowTesterController;
|
use App\Http\Controllers\DevTools\FlowTesterController;
|
||||||
use App\Http\Controllers\DocumentController;
|
use App\Http\Controllers\DocumentController;
|
||||||
use App\Http\Controllers\DocumentTemplateController;
|
use App\Http\Controllers\DocumentTemplateController;
|
||||||
|
use App\Http\Controllers\ESign\EsignApiController;
|
||||||
|
use App\Http\Controllers\ESign\EsignController;
|
||||||
|
use App\Http\Controllers\ESign\EsignPublicController;
|
||||||
use App\Http\Controllers\FcmController;
|
use App\Http\Controllers\FcmController;
|
||||||
use App\Http\Controllers\ItemFieldController;
|
use App\Http\Controllers\ItemFieldController;
|
||||||
use App\Http\Controllers\ItemManagementController;
|
use App\Http\Controllers\ItemManagementController;
|
||||||
use App\Http\Controllers\Additional\KioskController;
|
use App\Http\Controllers\Juil\ConstructionSitePhotoController;
|
||||||
|
use App\Http\Controllers\Juil\MeetingMinuteController;
|
||||||
|
use App\Http\Controllers\Juil\PlanningController;
|
||||||
use App\Http\Controllers\Lab\StrategyController;
|
use App\Http\Controllers\Lab\StrategyController;
|
||||||
use App\Http\Controllers\MenuController;
|
use App\Http\Controllers\MenuController;
|
||||||
use App\Http\Controllers\MenuSyncController;
|
use App\Http\Controllers\MenuSyncController;
|
||||||
|
use App\Http\Controllers\NumberingRuleController;
|
||||||
use App\Http\Controllers\PermissionController;
|
use App\Http\Controllers\PermissionController;
|
||||||
use App\Http\Controllers\PostController;
|
use App\Http\Controllers\PostController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use App\Http\Controllers\ProjectManagementController;
|
use App\Http\Controllers\ProjectManagementController;
|
||||||
use App\Http\Controllers\NumberingRuleController;
|
|
||||||
use App\Http\Controllers\QuoteFormulaController;
|
use App\Http\Controllers\QuoteFormulaController;
|
||||||
use App\Http\Controllers\RoleController;
|
use App\Http\Controllers\RoleController;
|
||||||
use App\Http\Controllers\RolePermissionController;
|
use App\Http\Controllers\RolePermissionController;
|
||||||
use App\Http\Controllers\Sales\SalesProductController;
|
use App\Http\Controllers\Sales\SalesProductController;
|
||||||
use App\Http\Controllers\Juil\ConstructionSitePhotoController;
|
|
||||||
use App\Http\Controllers\Juil\MeetingMinuteController;
|
|
||||||
use App\Http\Controllers\Juil\PlanningController;
|
|
||||||
use App\Http\Controllers\Stats\StatDashboardController;
|
use App\Http\Controllers\Stats\StatDashboardController;
|
||||||
use App\Http\Controllers\System\AiConfigController;
|
use App\Http\Controllers\System\AiConfigController;
|
||||||
use App\Http\Controllers\System\AiTokenUsageController;
|
use App\Http\Controllers\System\AiTokenUsageController;
|
||||||
@@ -654,6 +655,7 @@
|
|||||||
if (request()->header('HX-Request')) {
|
if (request()->header('HX-Request')) {
|
||||||
return response('', 200)->header('HX-Redirect', route('dashboard'));
|
return response('', 200)->header('HX-Redirect', route('dashboard'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('dashboard.index');
|
return view('dashboard.index');
|
||||||
})->name('dashboard');
|
})->name('dashboard');
|
||||||
|
|
||||||
@@ -707,6 +709,11 @@
|
|||||||
Route::get('/showroom', [KioskController::class, 'showroom'])->name('showroom');
|
Route::get('/showroom', [KioskController::class, 'showroom'])->name('showroom');
|
||||||
Route::get('/factory', [KioskController::class, 'factory'])->name('factory');
|
Route::get('/factory', [KioskController::class, 'factory'])->name('factory');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('notion-search')->name('notion-search.')->group(function () {
|
||||||
|
Route::get('/', [NotionSearchController::class, 'index'])->name('index');
|
||||||
|
Route::post('/search', [NotionSearchController::class, 'search'])->name('search');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user