- NotionService: Notion API 검색 + Gemini AI 답변 - AiConfig에 notion provider 추가 - 추가기능 > Notion 검색 채팅 UI
215 lines
8.4 KiB
PHP
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
|