Initial commit: import from sinmb79/Gov-chat-bot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
airkjw
2026-03-26 12:49:43 +09:00
commit a16c972dbb
104 changed files with 8063 additions and 0 deletions
+12
View File
@@ -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;"]
+16
View File
@@ -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>
+38
View File
@@ -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;
}
+19
View File
@@ -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"
}
}
+42
View File
@@ -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>
)
}
+81
View File
@@ -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' })
+81
View File
@@ -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
}
+41
View File
@@ -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)
+9
View File
@@ -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>
)
+81
View File
@@ -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>
)
}
+82
View File
@@ -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>
)
}
+105
View File
@@ -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>
)
}
+126
View File
@@ -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>
)
}
+73
View File
@@ -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>
)
}
+88
View File
@@ -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>
)
}
+159
View File
@@ -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>
)
}
+17
View File
@@ -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',
},
})
+114
View File
@@ -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')
})
})()
+38
View File
@@ -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>&lt;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"
&gt;&lt;/script&gt;</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>