Files
sam-manage/resources/views/additional/notion-search/index.blade.php
김보곤 aa3c9f4c3b feat: [additional] Notion 검색 기능 추가
- NotionService: Notion API 검색 + Gemini AI 답변
- AiConfig에 notion provider 추가
- 추가기능 > Notion 검색 채팅 UI
2026-02-22 23:04:16 +09:00

215 lines
8.4 KiB
PHP

@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