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([
|
||||
'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',
|
||||
'model' => 'nullable|string|max:100',
|
||||
'base_url' => 'nullable|string|max:255',
|
||||
@@ -108,7 +108,7 @@ public function update(Request $request, int $id): JsonResponse
|
||||
|
||||
$validated = $request->validate([
|
||||
'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',
|
||||
'model' => 'nullable|string|max:100',
|
||||
'base_url' => 'nullable|string|max:255',
|
||||
@@ -203,7 +203,7 @@ public function toggle(int $id): JsonResponse
|
||||
public function test(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'provider' => 'required|string|in:gemini,claude,openai,notion',
|
||||
'api_key' => 'nullable|string',
|
||||
'model' => 'required|string',
|
||||
'base_url' => 'nullable|string',
|
||||
@@ -279,7 +279,7 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr
|
||||
|
||||
return [
|
||||
'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' => '서비스 계정 파일 경로가 필요합니다.'];
|
||||
}
|
||||
|
||||
if (!file_exists($serviceAccountPath)) {
|
||||
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'])) {
|
||||
if (! $serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) {
|
||||
return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.'];
|
||||
}
|
||||
|
||||
// OAuth 토큰 획득
|
||||
$accessToken = $this->getVertexAiAccessToken($serviceAccount);
|
||||
if (!$accessToken) {
|
||||
if (! $accessToken) {
|
||||
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)
|
||||
->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
'Authorization' => 'Bearer '.$accessToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->post($url, [
|
||||
@@ -345,7 +345,7 @@ private function testGeminiVertexAi(string $model, string $projectId, string $re
|
||||
|
||||
// 상세 오류 메시지 추출
|
||||
$errorBody = $response->json();
|
||||
$errorMsg = $errorBody['error']['message'] ?? ('HTTP ' . $response->status());
|
||||
$errorMsg = $errorBody['error']['message'] ?? ('HTTP '.$response->status());
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
@@ -369,16 +369,16 @@ private function getVertexAiAccessToken(array $serviceAccount): ?string
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
if (! $privateKey) {
|
||||
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) {
|
||||
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', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
@@ -408,13 +408,13 @@ public function testGcs(Request $request): JsonResponse
|
||||
$serviceAccount = null;
|
||||
|
||||
// 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로)
|
||||
if (!empty($validated['service_account_json'])) {
|
||||
if (! empty($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);
|
||||
}
|
||||
|
||||
if (!$serviceAccount) {
|
||||
if (! $serviceAccount) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '서비스 계정 정보를 찾을 수 없습니다.',
|
||||
@@ -423,7 +423,7 @@ public function testGcs(Request $request): JsonResponse
|
||||
|
||||
// OAuth 토큰 획득
|
||||
$accessToken = $this->getGcsAccessToken($serviceAccount);
|
||||
if (!$accessToken) {
|
||||
if (! $accessToken) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => 'OAuth 토큰 획득 실패',
|
||||
@@ -432,7 +432,7 @@ public function testGcs(Request $request): JsonResponse
|
||||
|
||||
// 버킷 존재 확인
|
||||
$response = \Illuminate\Support\Facades\Http::timeout(10)
|
||||
->withHeaders(['Authorization' => 'Bearer ' . $accessToken])
|
||||
->withHeaders(['Authorization' => 'Bearer '.$accessToken])
|
||||
->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}");
|
||||
|
||||
if ($response->successful()) {
|
||||
@@ -444,7 +444,7 @@ public function testGcs(Request $request): JsonResponse
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '버킷 접근 실패: ' . $response->status(),
|
||||
'error' => '버킷 접근 실패: '.$response->status(),
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@@ -467,24 +467,24 @@ private function getGcsAccessToken(array $serviceAccount): ?string
|
||||
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now
|
||||
'iat' => $now,
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
if (! $privateKey) {
|
||||
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) {
|
||||
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', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
'assertion' => $jwt,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
|
||||
@@ -52,6 +52,7 @@ class AiConfig extends Model
|
||||
'claude' => 'https://api.anthropic.com/v1',
|
||||
'openai' => 'https://api.openai.com/v1',
|
||||
'gcs' => 'https://storage.googleapis.com',
|
||||
'notion' => 'https://api.notion.com/v1',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -62,12 +63,13 @@ class AiConfig extends Model
|
||||
'claude' => 'claude-sonnet-4-20250514',
|
||||
'openai' => 'gpt-4o',
|
||||
'gcs' => '-',
|
||||
'notion' => '2025-09-03',
|
||||
];
|
||||
|
||||
/**
|
||||
* AI Provider 목록 (GCS 제외)
|
||||
*/
|
||||
public const AI_PROVIDERS = ['gemini', 'claude', 'openai'];
|
||||
public const AI_PROVIDERS = ['gemini', 'claude', 'openai', 'notion'];
|
||||
|
||||
/**
|
||||
* 스토리지 Provider 목록
|
||||
@@ -94,6 +96,16 @@ public static function getActiveClaude(): ?self
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 Notion 설정 조회
|
||||
*/
|
||||
public static function getActiveNotion(): ?self
|
||||
{
|
||||
return self::where('provider', 'notion')
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider별 활성 설정 조회
|
||||
*/
|
||||
@@ -122,6 +134,7 @@ public function getProviderLabelAttribute(): string
|
||||
'claude' => 'Anthropic Claude',
|
||||
'openai' => 'OpenAI',
|
||||
'gcs' => 'Google Cloud Storage',
|
||||
'notion' => 'Notion',
|
||||
default => $this->provider,
|
||||
};
|
||||
}
|
||||
@@ -193,7 +206,7 @@ public function getMaskedApiKeyAttribute(): string
|
||||
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()) {
|
||||
return 'Vertex AI (서비스 계정)';
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
use App\Http\Controllers\Additional\KioskController;
|
||||
use App\Http\Controllers\Additional\NotionSearchController;
|
||||
use App\Http\Controllers\Api\BusinessCardOcrController;
|
||||
use App\Http\Controllers\ApiLogController;
|
||||
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\AuditLogController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
@@ -21,25 +20,27 @@
|
||||
use App\Http\Controllers\DevTools\FlowTesterController;
|
||||
use App\Http\Controllers\DocumentController;
|
||||
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\ItemFieldController;
|
||||
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\MenuController;
|
||||
use App\Http\Controllers\MenuSyncController;
|
||||
use App\Http\Controllers\NumberingRuleController;
|
||||
use App\Http\Controllers\PermissionController;
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ProjectManagementController;
|
||||
use App\Http\Controllers\NumberingRuleController;
|
||||
use App\Http\Controllers\QuoteFormulaController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use App\Http\Controllers\RolePermissionController;
|
||||
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\System\AiConfigController;
|
||||
use App\Http\Controllers\System\AiTokenUsageController;
|
||||
@@ -654,6 +655,7 @@
|
||||
if (request()->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('dashboard'));
|
||||
}
|
||||
|
||||
return view('dashboard.index');
|
||||
})->name('dashboard');
|
||||
|
||||
@@ -707,6 +709,11 @@
|
||||
Route::get('/showroom', [KioskController::class, 'showroom'])->name('showroom');
|
||||
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