Initial commit: import from sinmb79/Gov-chat-bot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SmartBot KR 관리 대시보드</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Pretendard', 'Apple SD Gothic Neo', -apple-system, sans-serif; background: #f5f6fa; color: #222; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# React Router — 모든 경로를 index.html로 전달
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 요청을 백엔드로 프록시
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /skill/ {
|
||||
proxy_pass http://backend:8000/skill/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /engine/ {
|
||||
proxy_pass http://backend:8000/engine/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://backend:8000/health;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "govbot-kr-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import FaqPage from './pages/FaqPage'
|
||||
import DocumentPage from './pages/DocumentPage'
|
||||
import ComplaintsPage from './pages/ComplaintsPage'
|
||||
import ModerationPage from './pages/ModerationPage'
|
||||
import SimulatorPage from './pages/SimulatorPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/docs" element={<DocumentPage />} />
|
||||
<Route path="/complaints" element={<ComplaintsPage />} />
|
||||
<Route path="/moderation" element={<ModerationPage />} />
|
||||
<Route path="/simulator" element={<SimulatorPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
const BASE = '/api'
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('token')
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const token = getToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || '오류가 발생했습니다.')
|
||||
}
|
||||
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const login = (tenant_id, email, password) =>
|
||||
request('POST', '/admin/auth/login', { tenant_id, email, password })
|
||||
|
||||
// Metrics
|
||||
export const getMetrics = () => request('GET', '/admin/metrics')
|
||||
|
||||
// FAQ
|
||||
export const listFaqs = () => request('GET', '/admin/faqs')
|
||||
export const createFaq = (data) => request('POST', '/admin/faqs', data)
|
||||
export const updateFaq = (id, data) => request('PUT', `/admin/faqs/${id}`, data)
|
||||
export const deleteFaq = (id) => request('DELETE', `/admin/faqs/${id}`)
|
||||
|
||||
// Documents
|
||||
export const listDocs = () => request('GET', '/admin/documents')
|
||||
export const approveDoc = (id) => request('POST', `/admin/documents/${id}/approve`)
|
||||
export const deleteDoc = (id) => request('DELETE', `/admin/documents/${id}`)
|
||||
export const uploadDoc = async (file) => {
|
||||
const token = getToken()
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await fetch(`${BASE}/admin/documents/upload`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || '업로드 실패')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Complaints
|
||||
export const listComplaints = (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.tier) qs.set('tier', params.tier)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
return request('GET', `/admin/complaints?${qs}`)
|
||||
}
|
||||
|
||||
// Moderation
|
||||
export const listRestrictions = () => request('GET', '/admin/moderation')
|
||||
export const escalateUser = (user_key) => request('POST', `/admin/moderation/${user_key}/escalate`)
|
||||
export const releaseUser = (user_key) => request('POST', `/admin/moderation/${user_key}/release`)
|
||||
|
||||
// Simulator
|
||||
export const simulate = (tenant_id, utterance) =>
|
||||
request('POST', '/engine/query', { tenant_id, utterance, user_key: 'simulator' })
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: '대시보드', icon: '📊' },
|
||||
{ to: '/faq', label: 'FAQ 관리', icon: '❓' },
|
||||
{ to: '/docs', label: '문서 관리', icon: '📄' },
|
||||
{ to: '/complaints', label: '문의 이력', icon: '📋' },
|
||||
{ to: '/moderation', label: '악성 감지', icon: '🚫' },
|
||||
{ to: '/simulator', label: '시뮬레이터', icon: '💬' },
|
||||
]
|
||||
|
||||
const s = {
|
||||
wrap: { display: 'flex', minHeight: '100vh' },
|
||||
sidebar: {
|
||||
width: 220, background: '#1a2540', color: '#e8ecf4', display: 'flex',
|
||||
flexDirection: 'column', padding: '0 0 16px',
|
||||
},
|
||||
brand: {
|
||||
padding: '20px 20px 16px', fontSize: 17, fontWeight: 700,
|
||||
borderBottom: '1px solid #2d3a5a', letterSpacing: '-0.3px',
|
||||
},
|
||||
navArea: { flex: 1, padding: '12px 8px' },
|
||||
link: (active) => ({
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
|
||||
borderRadius: 8, marginBottom: 2, textDecoration: 'none', fontSize: 14,
|
||||
color: active ? '#fff' : '#9bacd0',
|
||||
background: active ? '#2563eb' : 'transparent',
|
||||
fontWeight: active ? 600 : 400,
|
||||
}),
|
||||
userBox: {
|
||||
margin: '0 12px', padding: '10px 12px', background: '#2d3a5a',
|
||||
borderRadius: 8, fontSize: 12, color: '#9bacd0',
|
||||
},
|
||||
logoutBtn: {
|
||||
marginTop: 6, width: '100%', background: 'none', border: 'none',
|
||||
color: '#ef4444', cursor: 'pointer', textAlign: 'left', fontSize: 12, padding: 0,
|
||||
},
|
||||
main: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'auto' },
|
||||
header: {
|
||||
background: '#fff', borderBottom: '1px solid #e5e7eb',
|
||||
padding: '0 28px', height: 56, display: 'flex', alignItems: 'center',
|
||||
fontSize: 14, color: '#6b7280',
|
||||
},
|
||||
content: { padding: 28, flex: 1 },
|
||||
}
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={s.wrap}>
|
||||
<aside style={s.sidebar}>
|
||||
<div style={s.brand}>🤖 SmartBot KR</div>
|
||||
<nav style={s.navArea}>
|
||||
{nav.map(({ to, label, icon }) => (
|
||||
<NavLink key={to} to={to} end={to === '/'} style={({ isActive }) => s.link(isActive)}>
|
||||
<span>{icon}</span>{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div style={s.userBox}>
|
||||
<div style={{ marginBottom: 2, color: '#c8d3ea', fontWeight: 600 }}>{user?.email}</div>
|
||||
<div>{user?.tenant_id} · {user?.role}</div>
|
||||
<button style={s.logoutBtn} onClick={handleLogout}>로그아웃</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div style={s.main}>
|
||||
<header style={s.header}>SmartBot KR 관리 대시보드</header>
|
||||
<main style={s.content}>{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div style={{ padding: 40, textAlign: 'center' }}>로딩 중...</div>
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { login as apiLogin } from '../api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
const saved = localStorage.getItem('user')
|
||||
if (token && saved) {
|
||||
try { setUser(JSON.parse(saved)) } catch {}
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
const login = async (tenant_id, email, password) => {
|
||||
const data = await apiLogin(tenant_id, email, password)
|
||||
localStorage.setItem('token', data.access_token)
|
||||
const u = { email, tenant_id, role: data.role }
|
||||
localStorage.setItem('user', JSON.stringify(u))
|
||||
setUser(u)
|
||||
return u
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { listComplaints } from '../api'
|
||||
|
||||
const s = {
|
||||
title: { fontSize: 22, fontWeight: 700, color: '#1a2540', marginBottom: 20 },
|
||||
toolbar: { display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center', flexWrap: 'wrap' },
|
||||
select: { padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 13, background: '#fff' },
|
||||
table: { width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.07)' },
|
||||
th: { padding: '12px 16px', fontSize: 12, fontWeight: 600, color: '#6b7280', textAlign: 'left', background: '#f9fafb', borderBottom: '1px solid #f0f0f0' },
|
||||
td: { padding: '12px 16px', fontSize: 13, borderBottom: '1px solid #f5f5f5', verticalAlign: 'middle' },
|
||||
badge: (color) => ({
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 12,
|
||||
background: color + '20', color: color, fontWeight: 600,
|
||||
}),
|
||||
note: { fontSize: 12, color: '#9ca3af', marginBottom: 12 },
|
||||
}
|
||||
|
||||
const TIER_COLORS = { A: '#10b981', B: '#3b82f6', C: '#8b5cf6', D: '#f59e0b' }
|
||||
|
||||
export default function ComplaintsPage() {
|
||||
const [items, setItems] = useState([])
|
||||
const [tier, setTier] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = () =>
|
||||
listComplaints({ tier: tier || undefined, limit: 100 })
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e.message))
|
||||
|
||||
useEffect(() => { load() }, [tier])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={s.title}>문의 이력</div>
|
||||
<div style={s.note}>⚠️ 개인정보 보호 정책에 따라 발화 원문은 마스킹 처리되어 표시됩니다.</div>
|
||||
<div style={s.toolbar}>
|
||||
<select style={s.select} value={tier} onChange={(e) => setTier(e.target.value)}>
|
||||
<option value="">전체 Tier</option>
|
||||
<option value="A">Tier A (FAQ)</option>
|
||||
<option value="B">Tier B (RAG)</option>
|
||||
<option value="C">Tier C (LLM)</option>
|
||||
<option value="D">Tier D (폴백)</option>
|
||||
</select>
|
||||
<span style={{ fontSize: 13, color: '#6b7280' }}>{items.length}건</span>
|
||||
</div>
|
||||
{error && <div style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</div>}
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>시간</th>
|
||||
<th style={s.th}>사용자 키 (해시)</th>
|
||||
<th style={s.th}>발화 (마스킹)</th>
|
||||
<th style={s.th}>Tier</th>
|
||||
<th style={s.th}>소스</th>
|
||||
<th style={s.th}>응답(ms)</th>
|
||||
<th style={s.th}>타임아웃</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ ...s.td, textAlign: 'center', color: '#9ca3af' }}>이력이 없습니다.</td></tr>
|
||||
) : items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td style={s.td}>{item.created_at ? new Date(item.created_at).toLocaleString('ko-KR') : '-'}</td>
|
||||
<td style={{ ...s.td, fontFamily: 'monospace', fontSize: 12 }}>{item.user_key?.slice(0, 12)}...</td>
|
||||
<td style={{ ...s.td, maxWidth: 300, color: '#4b5563' }}>{item.utterance_masked || '-'}</td>
|
||||
<td style={s.td}>
|
||||
<span style={s.badge(TIER_COLORS[item.response_tier] || '#6b7280')}>
|
||||
{item.response_tier || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={s.td}>{item.response_source || '-'}</td>
|
||||
<td style={s.td}>{item.response_ms ?? '-'}</td>
|
||||
<td style={s.td}>{item.is_timeout ? '⚠️ 예' : '정상'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getMetrics } from '../api'
|
||||
|
||||
const s = {
|
||||
title: { fontSize: 22, fontWeight: 700, color: '#1a2540', marginBottom: 20 },
|
||||
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 16, marginBottom: 28 },
|
||||
card: {
|
||||
background: '#fff', borderRadius: 12, padding: '20px 22px',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.07)',
|
||||
},
|
||||
cardLabel: { fontSize: 12, color: '#6b7280', marginBottom: 6, fontWeight: 500 },
|
||||
cardVal: { fontSize: 28, fontWeight: 700, color: '#1a2540' },
|
||||
cardSub: { fontSize: 12, color: '#9ca3af', marginTop: 2 },
|
||||
tierGrid: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 28 },
|
||||
tierCard: (color) => ({
|
||||
background: '#fff', borderRadius: 12, padding: '16px 18px',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.07)', borderTop: `3px solid ${color}`,
|
||||
}),
|
||||
sectionTitle: { fontSize: 15, fontWeight: 700, color: '#374151', marginBottom: 14 },
|
||||
err: { color: '#6b7280', fontSize: 14 },
|
||||
}
|
||||
|
||||
const TIER_COLORS = { A: '#10b981', B: '#3b82f6', C: '#8b5cf6', D: '#f59e0b' }
|
||||
const TIER_LABELS = { A: 'FAQ 직접 응답', B: 'RAG 템플릿', C: 'LLM 재서술', D: '폴백 안내' }
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [metrics, setMetrics] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getMetrics()
|
||||
.then(setMetrics)
|
||||
.catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
if (error) return <div style={s.err}>메트릭 로드 실패: {error}</div>
|
||||
if (!metrics) return <div style={s.err}>로딩 중...</div>
|
||||
|
||||
const total = metrics.total_count || 0
|
||||
const tierCounts = metrics.tier_counts || {}
|
||||
const rate = (n) => total ? Math.round(n / total * 100) : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={s.title}>대시보드</div>
|
||||
|
||||
<div style={s.grid}>
|
||||
<div style={s.card}>
|
||||
<div style={s.cardLabel}>총 문의 수</div>
|
||||
<div style={s.cardVal}>{total.toLocaleString()}</div>
|
||||
<div style={s.cardSub}>누적</div>
|
||||
</div>
|
||||
<div style={s.card}>
|
||||
<div style={s.cardLabel}>자동 응답률</div>
|
||||
<div style={s.cardVal}>{rate((tierCounts.A || 0) + (tierCounts.B || 0) + (tierCounts.C || 0))}%</div>
|
||||
<div style={s.cardSub}>A+B+C Tier</div>
|
||||
</div>
|
||||
<div style={s.card}>
|
||||
<div style={s.cardLabel}>FAQ 응답률</div>
|
||||
<div style={s.cardVal}>{rate(tierCounts.A || 0)}%</div>
|
||||
<div style={s.cardSub}>Tier A</div>
|
||||
</div>
|
||||
<div style={s.card}>
|
||||
<div style={s.cardLabel}>타임아웃</div>
|
||||
<div style={s.cardVal}>{metrics.timeout_count || 0}</div>
|
||||
<div style={s.cardSub}>4.5초 초과</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={s.sectionTitle}>Tier별 분류</div>
|
||||
<div style={s.tierGrid}>
|
||||
{['A', 'B', 'C', 'D'].map((t) => (
|
||||
<div key={t} style={s.tierCard(TIER_COLORS[t])}>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 4 }}>Tier {t} — {TIER_LABELS[t]}</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: '#1a2540' }}>{tierCounts[t] || 0}</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af' }}>{rate(tierCounts[t] || 0)}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { listDocs, uploadDoc, approveDoc, deleteDoc } from '../api'
|
||||
|
||||
const s = {
|
||||
title: { fontSize: 22, fontWeight: 700, color: '#1a2540', marginBottom: 20 },
|
||||
toolbar: { display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center' },
|
||||
btn: (color = '#2563eb') => ({
|
||||
padding: '8px 16px', background: color, color: '#fff',
|
||||
border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}),
|
||||
table: { width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.07)' },
|
||||
th: { padding: '12px 16px', fontSize: 12, fontWeight: 600, color: '#6b7280', textAlign: 'left', background: '#f9fafb', borderBottom: '1px solid #f0f0f0' },
|
||||
td: { padding: '12px 16px', fontSize: 14, borderBottom: '1px solid #f5f5f5', verticalAlign: 'middle' },
|
||||
err: { color: '#ef4444', fontSize: 13, marginBottom: 12 },
|
||||
badge: (color) => ({
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 12,
|
||||
background: color + '20', color: color, fontWeight: 600,
|
||||
}),
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
pending: '#f59e0b',
|
||||
processed: '#10b981',
|
||||
parse_failed: '#ef4444',
|
||||
embedding_unavailable: '#8b5cf6',
|
||||
}
|
||||
|
||||
export default function DocumentPage() {
|
||||
const [docs, setDocs] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileRef = useRef()
|
||||
|
||||
const load = () => listDocs().then(setDocs).catch((e) => setError(e.message))
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
await uploadDoc(file)
|
||||
load()
|
||||
} catch (err) {
|
||||
alert('업로드 실패: ' + err.message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
fileRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const approve = async (id) => {
|
||||
try { await approveDoc(id); load() } catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
const del = async (id) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return
|
||||
try { await deleteDoc(id); load() } catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={s.title}>문서 관리</div>
|
||||
<div style={s.toolbar}>
|
||||
<button style={s.btn()} onClick={() => fileRef.current.click()} disabled={uploading}>
|
||||
{uploading ? '업로드 중...' : '+ 문서 업로드'}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".txt,.pdf,.docx,.md" style={{ display: 'none' }} onChange={handleUpload} />
|
||||
<span style={{ fontSize: 13, color: '#6b7280' }}>총 {docs.length}개 · txt, pdf, docx, md 지원</span>
|
||||
</div>
|
||||
{error && <div style={s.err}>{error}</div>}
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>파일명</th>
|
||||
<th style={s.th}>상태</th>
|
||||
<th style={s.th}>활성</th>
|
||||
<th style={s.th}>업로드일</th>
|
||||
<th style={s.th}>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ ...s.td, textAlign: 'center', color: '#9ca3af' }}>문서가 없습니다.</td></tr>
|
||||
) : docs.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td style={s.td}>{d.filename}</td>
|
||||
<td style={s.td}>
|
||||
<span style={s.badge(STATUS_COLOR[d.status] || '#6b7280')}>{d.status}</span>
|
||||
</td>
|
||||
<td style={s.td}>{d.is_active ? '✅ 활성' : '⏸ 비활성'}</td>
|
||||
<td style={s.td}>{d.created_at ? new Date(d.created_at).toLocaleDateString('ko-KR') : '-'}</td>
|
||||
<td style={s.td}>
|
||||
{!d.is_active && d.status === 'processed' && (
|
||||
<button style={{ ...s.btn('#10b981'), marginRight: 6, padding: '4px 10px' }} onClick={() => approve(d.id)}>승인</button>
|
||||
)}
|
||||
<button style={{ ...s.btn('#ef4444'), padding: '4px 10px' }} onClick={() => del(d.id)}>삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { listFaqs, createFaq, updateFaq, deleteFaq } from '../api'
|
||||
|
||||
const s = {
|
||||
title: { fontSize: 22, fontWeight: 700, color: '#1a2540', marginBottom: 20 },
|
||||
toolbar: { display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center' },
|
||||
btn: (color = '#2563eb') => ({
|
||||
padding: '8px 16px', background: color, color: '#fff',
|
||||
border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}),
|
||||
table: { width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.07)' },
|
||||
th: { padding: '12px 16px', fontSize: 12, fontWeight: 600, color: '#6b7280', textAlign: 'left', background: '#f9fafb', borderBottom: '1px solid #f0f0f0' },
|
||||
td: { padding: '12px 16px', fontSize: 14, borderBottom: '1px solid #f5f5f5', verticalAlign: 'top' },
|
||||
modal: {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
|
||||
},
|
||||
modalBox: { background: '#fff', borderRadius: 16, padding: '32px 28px', width: 520, maxHeight: '80vh', overflowY: 'auto' },
|
||||
label: { display: 'block', fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 6 },
|
||||
input: { width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, marginBottom: 16 },
|
||||
textarea: { width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, marginBottom: 16, minHeight: 120, resize: 'vertical' },
|
||||
err: { color: '#ef4444', fontSize: 13, marginBottom: 12 },
|
||||
}
|
||||
|
||||
function FaqModal({ faq, onClose, onSave }) {
|
||||
const [form, setForm] = useState({ question: faq?.question || '', answer: faq?.answer || '', category: faq?.category || '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (faq) await updateFaq(faq.id, form)
|
||||
else await createFaq(form)
|
||||
onSave()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={s.modal} onClick={onClose}>
|
||||
<div style={s.modalBox} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, marginBottom: 20 }}>{faq ? 'FAQ 수정' : 'FAQ 추가'}</div>
|
||||
{error && <div style={s.err}>{error}</div>}
|
||||
<form onSubmit={submit}>
|
||||
<label style={s.label}>카테고리</label>
|
||||
<input style={s.input} value={form.category} onChange={set('category')} placeholder="메뉴, 배송, 이용안내 등" />
|
||||
<label style={s.label}>질문 *</label>
|
||||
<input style={s.input} value={form.question} onChange={set('question')} required />
|
||||
<label style={s.label}>답변 *</label>
|
||||
<textarea style={s.textarea} value={form.answer} onChange={set('answer')} required />
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button type="button" style={s.btn('#6b7280')} onClick={onClose}>취소</button>
|
||||
<button type="submit" style={s.btn()} disabled={busy}>{busy ? '저장 중...' : '저장'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FaqPage() {
|
||||
const [faqs, setFaqs] = useState([])
|
||||
const [editing, setEditing] = useState(null) // null=closed, false=new, obj=edit
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = () => listFaqs().then(setFaqs).catch((e) => setError(e.message))
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const del = async (id) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return
|
||||
try { await deleteFaq(id); load() } catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={s.title}>FAQ 관리</div>
|
||||
<div style={s.toolbar}>
|
||||
<button style={s.btn()} onClick={() => setEditing(false)}>+ FAQ 추가</button>
|
||||
<span style={{ fontSize: 13, color: '#6b7280' }}>총 {faqs.length}개</span>
|
||||
</div>
|
||||
{error && <div style={s.err}>{error}</div>}
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>카테고리</th>
|
||||
<th style={s.th}>질문</th>
|
||||
<th style={s.th}>답변 (일부)</th>
|
||||
<th style={s.th}>조회수</th>
|
||||
<th style={s.th}>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{faqs.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ ...s.td, textAlign: 'center', color: '#9ca3af' }}>FAQ가 없습니다.</td></tr>
|
||||
) : faqs.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td style={s.td}><span style={{ fontSize: 12, background: '#e0e7ff', color: '#3730a3', padding: '2px 8px', borderRadius: 12 }}>{f.category || '-'}</span></td>
|
||||
<td style={{ ...s.td, maxWidth: 200 }}>{f.question}</td>
|
||||
<td style={{ ...s.td, maxWidth: 260, color: '#4b5563' }}>{(f.answer || '').slice(0, 60)}{f.answer?.length > 60 ? '...' : ''}</td>
|
||||
<td style={s.td}>{f.hit_count ?? 0}</td>
|
||||
<td style={s.td}>
|
||||
<button style={{ ...s.btn('#f59e0b'), marginRight: 6, padding: '4px 10px' }} onClick={() => setEditing(f)}>수정</button>
|
||||
<button style={{ ...s.btn('#ef4444'), padding: '4px 10px' }} onClick={() => del(f.id)}>삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{editing !== null && (
|
||||
<FaqModal
|
||||
faq={editing || null}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={() => { setEditing(null); load() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const s = {
|
||||
page: {
|
||||
minHeight: '100vh', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', background: '#f5f6fa',
|
||||
},
|
||||
card: {
|
||||
background: '#fff', borderRadius: 16, padding: '40px 36px',
|
||||
width: 380, boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
},
|
||||
title: { fontSize: 22, fontWeight: 700, marginBottom: 4, color: '#1a2540' },
|
||||
sub: { fontSize: 13, color: '#6b7280', marginBottom: 28 },
|
||||
label: { display: 'block', fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 6 },
|
||||
input: {
|
||||
width: '100%', padding: '10px 12px', border: '1px solid #d1d5db',
|
||||
borderRadius: 8, fontSize: 14, outline: 'none', marginBottom: 16,
|
||||
},
|
||||
btn: {
|
||||
width: '100%', padding: '12px', background: '#2563eb', color: '#fff',
|
||||
border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||
cursor: 'pointer', marginTop: 4,
|
||||
},
|
||||
err: { color: '#ef4444', fontSize: 13, marginTop: 12, textAlign: 'center' },
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [form, setForm] = useState({ tenant_id: '', email: '', password: '' })
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await login(form.tenant_id, form.email, form.password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={s.card}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>🤖</div>
|
||||
<div style={s.title}>SmartBot KR</div>
|
||||
<div style={s.sub}>관리자 로그인</div>
|
||||
<form onSubmit={submit}>
|
||||
<label style={s.label}>조직 ID (tenant_id)</label>
|
||||
<input style={s.input} value={form.tenant_id} onChange={set('tenant_id')} required placeholder="my-shop" />
|
||||
<label style={s.label}>이메일</label>
|
||||
<input style={s.input} type="email" value={form.email} onChange={set('email')} required placeholder="admin@example.com" />
|
||||
<label style={s.label}>비밀번호</label>
|
||||
<input style={s.input} type="password" value={form.password} onChange={set('password')} required />
|
||||
<button style={s.btn} type="submit" disabled={busy}>
|
||||
{busy ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <div style={s.err}>{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { listRestrictions, escalateUser, releaseUser } from '../api'
|
||||
|
||||
const LEVEL_INFO = [
|
||||
{ label: 'NORMAL', color: '#10b981' },
|
||||
{ label: 'WARNED', color: '#f59e0b' },
|
||||
{ label: 'THROTTLED', color: '#f97316' },
|
||||
{ label: 'SUSPENDED', color: '#ef4444' },
|
||||
{ label: 'BLOCKED', color: '#7f1d1d' },
|
||||
{ label: 'BANNED', color: '#1e1e1e' },
|
||||
]
|
||||
|
||||
const s = {
|
||||
title: { fontSize: 22, fontWeight: 700, color: '#1a2540', marginBottom: 20 },
|
||||
table: { width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.07)' },
|
||||
th: { padding: '12px 16px', fontSize: 12, fontWeight: 600, color: '#6b7280', textAlign: 'left', background: '#f9fafb', borderBottom: '1px solid #f0f0f0' },
|
||||
td: { padding: '12px 16px', fontSize: 14, borderBottom: '1px solid #f5f5f5', verticalAlign: 'middle' },
|
||||
btn: (color = '#2563eb') => ({
|
||||
padding: '4px 10px', background: color, color: '#fff',
|
||||
border: 'none', borderRadius: 6, fontSize: 12, fontWeight: 600, cursor: 'pointer', marginRight: 6,
|
||||
}),
|
||||
badge: (color) => ({
|
||||
fontSize: 11, padding: '3px 10px', borderRadius: 12,
|
||||
background: color + '20', color: color, fontWeight: 700,
|
||||
}),
|
||||
}
|
||||
|
||||
export default function ModerationPage() {
|
||||
const [items, setItems] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = () => listRestrictions().then(setItems).catch((e) => setError(e.message))
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const escalate = async (user_key) => {
|
||||
try { await escalateUser(user_key); load() } catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
const release = async (user_key) => {
|
||||
if (!confirm('제한을 해제하시겠습니까?')) return
|
||||
try { await releaseUser(user_key); load() } catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={s.title}>악성 감지 · 이용 제한</div>
|
||||
{error && <div style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</div>}
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>사용자 키</th>
|
||||
<th style={s.th}>현재 레벨</th>
|
||||
<th style={s.th}>사유</th>
|
||||
<th style={s.th}>만료</th>
|
||||
<th style={s.th}>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ ...s.td, textAlign: 'center', color: '#9ca3af' }}>제한 사용자가 없습니다.</td></tr>
|
||||
) : items.map((item) => {
|
||||
const info = LEVEL_INFO[item.level] || LEVEL_INFO[0]
|
||||
const canRelease = item.level >= 1
|
||||
const canEscalate = item.level < 4 // editor can escalate up to SUSPENDED(3)
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td style={{ ...s.td, fontFamily: 'monospace', fontSize: 12 }}>{item.user_key}</td>
|
||||
<td style={s.td}>
|
||||
<span style={s.badge(info.color)}>{item.level} {info.label}</span>
|
||||
</td>
|
||||
<td style={s.td}>{item.reason || '-'}</td>
|
||||
<td style={s.td}>{item.expires_at ? new Date(item.expires_at).toLocaleString('ko-KR') : '영구'}</td>
|
||||
<td style={s.td}>
|
||||
{canEscalate && (
|
||||
<button style={s.btn('#ef4444')} onClick={() => escalate(item.user_key)}>단계 상향</button>
|
||||
)}
|
||||
{canRelease && (
|
||||
<button style={s.btn('#10b981')} onClick={() => release(item.user_key)}>해제</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { simulate } from '../api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const TIER_COLOR = { A: '#10b981', B: '#3b82f6', C: '#8b5cf6', D: '#f59e0b' }
|
||||
|
||||
const s = {
|
||||
title: { fontSize: 22, fontWeight: 700, color: '#1a2540', marginBottom: 20 },
|
||||
wrap: { display: 'flex', gap: 20, height: 'calc(100vh - 180px)' },
|
||||
chatArea: {
|
||||
flex: 1, background: '#fff', borderRadius: 12, boxShadow: '0 1px 4px rgba(0,0,0,0.07)',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
},
|
||||
messages: { flex: 1, overflowY: 'auto', padding: '20px 20px 0' },
|
||||
inputRow: {
|
||||
display: 'flex', gap: 10, padding: '16px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
},
|
||||
input: {
|
||||
flex: 1, padding: '10px 14px', border: '1px solid #d1d5db',
|
||||
borderRadius: 24, fontSize: 14, outline: 'none',
|
||||
},
|
||||
sendBtn: {
|
||||
padding: '10px 20px', background: '#2563eb', color: '#fff',
|
||||
border: 'none', borderRadius: 24, fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||
},
|
||||
msg: (role) => ({
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
flexDirection: role === 'user' ? 'row-reverse' : 'row',
|
||||
alignItems: 'flex-end', gap: 8,
|
||||
}),
|
||||
bubble: (role) => ({
|
||||
maxWidth: '75%', padding: '10px 14px', borderRadius: role === 'user' ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||||
background: role === 'user' ? '#2563eb' : '#f3f4f6',
|
||||
color: role === 'user' ? '#fff' : '#1a2540',
|
||||
fontSize: 14, lineHeight: 1.5, whiteSpace: 'pre-wrap',
|
||||
}),
|
||||
meta: { fontSize: 11, color: '#9ca3af', marginTop: 4 },
|
||||
sidePanel: { width: 240, background: '#fff', borderRadius: 12, padding: 20, boxShadow: '0 1px 4px rgba(0,0,0,0.07)', height: 'fit-content' },
|
||||
sideTitle: { fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 12 },
|
||||
examples: { listStyle: 'none' },
|
||||
exampleBtn: {
|
||||
width: '100%', padding: '8px 10px', border: '1px solid #e5e7eb',
|
||||
borderRadius: 8, fontSize: 12, cursor: 'pointer', textAlign: 'left',
|
||||
background: '#f9fafb', marginBottom: 6, color: '#374151',
|
||||
},
|
||||
}
|
||||
|
||||
// 기관 유형별 예시 질문 (지자체 · 소상공인 · 일반기업 혼합)
|
||||
const EXAMPLES = [
|
||||
// 지자체
|
||||
'주민등록등본은 어디서 발급하나요?',
|
||||
'전입신고 절차가 어떻게 되나요?',
|
||||
// 음식점/카페
|
||||
'오늘 영업시간이 어떻게 되나요?',
|
||||
'주차 가능한가요?',
|
||||
// 쇼핑몰/온라인
|
||||
'배송은 얼마나 걸리나요?',
|
||||
'교환·환불 정책이 궁금해요.',
|
||||
// 공통
|
||||
'담당자 연락처 알려주세요.',
|
||||
]
|
||||
|
||||
export default function SimulatorPage() {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const { user } = useAuth()
|
||||
const bottomRef = useRef()
|
||||
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||
|
||||
const send = async (text) => {
|
||||
const utterance = text || input.trim()
|
||||
if (!utterance || busy) return
|
||||
setInput('')
|
||||
setMessages((m) => [...m, { role: 'user', text: utterance }])
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await simulate(user.tenant_id, utterance)
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
role: 'bot',
|
||||
text: res.answer,
|
||||
tier: res.tier,
|
||||
source: res.source,
|
||||
ms: res.elapsed_ms,
|
||||
citations: res.citations,
|
||||
},
|
||||
])
|
||||
} catch (err) {
|
||||
setMessages((m) => [...m, { role: 'bot', text: '오류: ' + err.message, tier: 'D' }])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={s.title}>응답 시뮬레이터</div>
|
||||
<div style={s.wrap}>
|
||||
<div style={s.chatArea}>
|
||||
<div style={s.messages}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#9ca3af', marginTop: 40, fontSize: 14 }}>
|
||||
질문을 입력하여 AI 응답을 테스트하세요.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} style={s.msg(m.role)}>
|
||||
<div>
|
||||
<div style={s.bubble(m.role)}>{m.text}</div>
|
||||
{m.role === 'bot' && (
|
||||
<div style={s.meta}>
|
||||
<span style={{ color: TIER_COLOR[m.tier], fontWeight: 600 }}>Tier {m.tier}</span>
|
||||
{' · '}{m.source}{m.ms != null ? ` · ${m.ms}ms` : ''}
|
||||
{m.citations?.map((c, j) => (
|
||||
<span key={j}> · 📎 {c.doc}{c.date ? ` (${c.date})` : ''}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{busy && (
|
||||
<div style={s.msg('bot')}>
|
||||
<div style={{ ...s.bubble('bot'), color: '#9ca3af' }}>응답 중...</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<div style={s.inputRow}>
|
||||
<input
|
||||
style={s.input}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && send()}
|
||||
placeholder="질문을 입력하세요 (Enter 전송)"
|
||||
disabled={busy}
|
||||
/>
|
||||
<button style={s.sendBtn} onClick={() => send()} disabled={busy}>전송</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={s.sidePanel}>
|
||||
<div style={s.sideTitle}>예시 질문</div>
|
||||
<ul style={s.examples}>
|
||||
{EXAMPLES.map((ex, i) => (
|
||||
<li key={i}>
|
||||
<button style={s.exampleBtn} onClick={() => send(ex)}>{ex}</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* GovBot KR 웹 채팅 위젯 v1.0
|
||||
* 사용법: <script src="govbot-widget.js" data-tenant="your-slug" data-api="https://your-api.com"></script>
|
||||
*/
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
||||
var cfg = (function () {
|
||||
var el = document.currentScript
|
||||
return {
|
||||
tenant: el ? el.getAttribute('data-tenant') || '' : '',
|
||||
api: el ? el.getAttribute('data-api') || '' : '',
|
||||
title: el ? el.getAttribute('data-title') || 'AI 민원 도우미' : 'AI 민원 도우미',
|
||||
color: el ? el.getAttribute('data-color') || '#2563eb' : '#2563eb',
|
||||
}
|
||||
})()
|
||||
|
||||
var style = document.createElement('style')
|
||||
style.textContent = [
|
||||
'#govbot-fab{position:fixed;bottom:24px;right:24px;width:56px;height:56px;border-radius:50%;background:' + cfg.color + ';color:#fff;border:none;font-size:26px;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:9999;display:flex;align-items:center;justify-content:center}',
|
||||
'#govbot-window{position:fixed;bottom:90px;right:24px;width:360px;height:520px;background:#fff;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.15);z-index:9998;display:none;flex-direction:column;overflow:hidden;font-family:Apple SD Gothic Neo,system-ui,sans-serif}',
|
||||
'#govbot-window.open{display:flex}',
|
||||
'#govbot-header{background:' + cfg.color + ';color:#fff;padding:14px 16px;font-weight:700;font-size:15px;display:flex;justify-content:space-between;align-items:center}',
|
||||
'#govbot-close{background:none;border:none;color:#fff;font-size:20px;cursor:pointer;padding:0;line-height:1}',
|
||||
'#govbot-msgs{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px}',
|
||||
'.govbot-msg{max-width:80%;padding:10px 13px;border-radius:14px;font-size:13px;line-height:1.5;word-break:break-word}',
|
||||
'.govbot-msg.user{align-self:flex-end;background:' + cfg.color + ';color:#fff;border-bottom-right-radius:4px}',
|
||||
'.govbot-msg.bot{align-self:flex-start;background:#f3f4f6;color:#222;border-bottom-left-radius:4px}',
|
||||
'.govbot-meta{font-size:10px;color:#9ca3af;margin-top:3px;align-self:flex-start}',
|
||||
'#govbot-form{display:flex;gap:8px;padding:12px;border-top:1px solid #f0f0f0}',
|
||||
'#govbot-input{flex:1;padding:9px 13px;border:1px solid #d1d5db;border-radius:20px;font-size:13px;outline:none}',
|
||||
'#govbot-send{padding:9px 16px;background:' + cfg.color + ';color:#fff;border:none;border-radius:20px;font-size:13px;font-weight:600;cursor:pointer}',
|
||||
].join('')
|
||||
document.head.appendChild(style)
|
||||
|
||||
var fab = document.createElement('button')
|
||||
fab.id = 'govbot-fab'
|
||||
fab.title = cfg.title
|
||||
fab.textContent = '💬'
|
||||
|
||||
var win = document.createElement('div')
|
||||
win.id = 'govbot-window'
|
||||
win.innerHTML = [
|
||||
'<div id="govbot-header">' + cfg.title + '<button id="govbot-close">✕</button></div>',
|
||||
'<div id="govbot-msgs"></div>',
|
||||
'<form id="govbot-form"><input id="govbot-input" type="text" placeholder="질문을 입력하세요..." autocomplete="off" /><button id="govbot-send" type="submit">전송</button></form>',
|
||||
].join('')
|
||||
|
||||
document.body.appendChild(fab)
|
||||
document.body.appendChild(win)
|
||||
|
||||
var msgs = win.querySelector('#govbot-msgs')
|
||||
var input = win.querySelector('#govbot-input')
|
||||
var form = win.querySelector('#govbot-form')
|
||||
|
||||
function addMsg(role, text, meta) {
|
||||
var div = document.createElement('div')
|
||||
div.className = 'govbot-msg ' + role
|
||||
div.textContent = text
|
||||
msgs.appendChild(div)
|
||||
if (meta) {
|
||||
var m = document.createElement('div')
|
||||
m.className = 'govbot-meta'
|
||||
m.textContent = meta
|
||||
msgs.appendChild(m)
|
||||
}
|
||||
msgs.scrollTop = msgs.scrollHeight
|
||||
}
|
||||
|
||||
function greet() {
|
||||
addMsg('bot', '안녕하세요! 무엇을 도와드릴까요? 궁금하신 민원 사항을 입력해주세요.')
|
||||
}
|
||||
|
||||
var busy = false
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault()
|
||||
var text = input.value.trim()
|
||||
if (!text || busy) return
|
||||
input.value = ''
|
||||
addMsg('user', text)
|
||||
busy = true
|
||||
|
||||
var endpoint = cfg.api + '/skill/' + cfg.tenant
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userRequest: {
|
||||
utterance: text,
|
||||
user: { id: 'web-' + Math.random().toString(36).slice(2, 10) },
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (data) {
|
||||
var answer = ''
|
||||
try { answer = data.template.outputs[0].simpleText.text } catch (ex) { answer = '응답을 받아오지 못했습니다.' }
|
||||
addMsg('bot', answer)
|
||||
})
|
||||
.catch(function () { addMsg('bot', '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.') })
|
||||
.finally(function () { busy = false })
|
||||
})
|
||||
|
||||
fab.addEventListener('click', function () {
|
||||
var isOpen = win.classList.toggle('open')
|
||||
if (isOpen && msgs.children.length === 0) greet()
|
||||
if (isOpen) input.focus()
|
||||
})
|
||||
|
||||
win.querySelector('#govbot-close').addEventListener('click', function () {
|
||||
win.classList.remove('open')
|
||||
})
|
||||
})()
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SmartBot KR 웹 채팅 위젯 데모</title>
|
||||
<style>
|
||||
body { font-family: 'Apple SD Gothic Neo', sans-serif; background: #f0f4f8; margin: 0; padding: 40px; }
|
||||
.hero { max-width: 700px; margin: 0 auto; }
|
||||
h1 { color: #1a2540; margin-bottom: 8px; }
|
||||
p { color: #6b7280; line-height: 1.6; margin-bottom: 20px; }
|
||||
pre { background: #1e293b; color: #e2e8f0; padding: 20px; border-radius: 12px; overflow-x: auto; font-size: 13px; line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero">
|
||||
<h1>🤖 SmartBot KR 위젯 데모</h1>
|
||||
<p>오른쪽 아래의 💬 버튼을 클릭하여 채팅 위젯을 확인하세요.</p>
|
||||
<p>홈페이지에 아래 코드 한 줄을 추가하면 위젯이 바로 활성화됩니다.</p>
|
||||
<pre><script
|
||||
src="https://your-server.com/widget/smartbot-widget.js"
|
||||
data-tenant="your-id"
|
||||
data-api="https://your-api.com"
|
||||
data-title="AI 도우미"
|
||||
data-color="#2563eb"
|
||||
></script></pre>
|
||||
</div>
|
||||
|
||||
<!-- 위젯 삽입 예시 (로컬 API 사용) -->
|
||||
<script
|
||||
src="./govbot-widget.js"
|
||||
data-tenant="demo"
|
||||
data-api="http://localhost:8000"
|
||||
data-title="AI 도우미"
|
||||
data-color="#2563eb"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user