feat: [additional] Notion 검색 기능 추가

- NotionService: Notion API 검색 + Gemini AI 답변
- AiConfig에 notion provider 추가
- 추가기능 > Notion 검색 채팅 UI
This commit is contained in:
김보곤
2026-02-22 23:04:16 +09:00
parent f8b0843763
commit aa3c9f4c3b
6 changed files with 600 additions and 33 deletions

View 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,
]);
}
}
}

View File

@@ -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()) {

View File

@@ -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 키';
}
}

View 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;
}
}

View 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

View File

@@ -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');
});
});
/*