diff --git a/deploy/docker-compose-ollama.yml b/deploy/docker-compose-ollama.yml new file mode 100644 index 0000000..daf89c4 --- /dev/null +++ b/deploy/docker-compose-ollama.yml @@ -0,0 +1,21 @@ +services: + ollama: + image: ollama/ollama:latest + container_name: kcg-ollama + restart: unless-stopped + ports: + - "11434:11434" + volumes: + - /home/kcg-ollama/data:/root/.ollama + deploy: + resources: + limits: + memory: 64G + reservations: + memory: 40G + environment: + - OLLAMA_NUM_PARALLEL=4 + - OLLAMA_MAX_LOADED_MODELS=1 + - OLLAMA_KEEP_ALIVE=24h + - OLLAMA_FLASH_ATTENTION=1 + - OLLAMA_NUM_THREADS=48 diff --git a/deploy/nginx-kcg.conf b/deploy/nginx-kcg.conf index 05f0b60..8626455 100644 --- a/deploy/nginx-kcg.conf +++ b/deploy/nginx-kcg.conf @@ -20,6 +20,21 @@ server { try_files $uri $uri/ /index.html; } + # ── AI Chat (SSE → Python prediction on redis-211) ── + location /api/prediction-chat { + rewrite ^/api/prediction-chat(.*)$ /api/v1/chat$1 break; + proxy_pass http://192.168.1.18:8001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 120s; + proxy_set_header Connection ''; + chunked_transfer_encoding off; + } + # ── Backend API (direct) ── location /api/ { proxy_pass http://127.0.0.1:8080/api/; diff --git a/frontend/src/components/common/EventLog.tsx b/frontend/src/components/common/EventLog.tsx index 0caf308..2938e49 100644 --- a/frontend/src/components/common/EventLog.tsx +++ b/frontend/src/components/common/EventLog.tsx @@ -885,12 +885,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, {/* AI 해양분석 챗 — 한국 탭 전용 */} {dashboardTab === 'korea' && ( - + )} ); diff --git a/frontend/src/components/korea/AiChatPanel.tsx b/frontend/src/components/korea/AiChatPanel.tsx index 611f137..28669c7 100644 --- a/frontend/src/components/korea/AiChatPanel.tsx +++ b/frontend/src/components/korea/AiChatPanel.tsx @@ -1,73 +1,76 @@ import { useState, useRef, useEffect, useCallback } from 'react'; -import type { Ship } from '../../types'; +import { useAuth } from '../../hooks/useAuth'; interface ChatMessage { - role: 'user' | 'assistant' | 'system'; + role: 'user' | 'assistant'; content: string; timestamp: number; + isStreaming?: boolean; } -interface Props { - ships: Ship[]; - koreanShipCount: number; - chineseShipCount: number; - totalShipCount: number; +const AI_CHAT_URL = '/api/prediction-chat'; + +/** assistant 메시지에서 thinking(JSON tool call, 구분선 등)과 답변을 분리 */ +function splitThinking(content: string): { thinking: string; answer: string } { + // 패턴: ```json...``` 블록 + ---\n_데이터 조회 완료..._\n\n 까지가 thinking + const thinkingPattern = /^([\s\S]*?```json[\s\S]*?```[\s\S]*?---\n_[^_]*_\n*)/; + const match = content.match(thinkingPattern); + if (match) { + return { thinking: match[1].trim(), answer: content.slice(match[0].length).trim() }; + } + // ```json 블록만 있고 답변이 아직 안 온 경우 (스트리밍 중) + const jsonOnly = /^([\s\S]*```json[\s\S]*?```[\s\S]*)$/; + const m2 = content.match(jsonOnly); + if (m2 && !content.includes('---')) { + return { thinking: m2[1].trim(), answer: '' }; + } + return { thinking: '', answer: content }; } -// TODO: Python FastAPI 기반 해양분석 AI API로 전환 예정 -const AI_CHAT_URL = '/api/kcg/ai/chat'; +export function AiChatPanel() { + const { user } = useAuth(); + const userId = user?.email ?? 'anonymous'; -function buildSystemPrompt(props: Props): string { - const { ships, koreanShipCount, chineseShipCount, totalShipCount } = props; - - // 선박 유형별 통계 - const byType: Record = {}; - const byFlag: Record = {}; - ships.forEach(s => { - byType[s.category || 'unknown'] = (byType[s.category || 'unknown'] || 0) + 1; - byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1; - }); - - // 중국 어선 통계 - const cnFishing = ships.filter(s => s.flag === 'CN' && (s.category === 'fishing' || s.typecode === '30')); - const cnFishingOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 5); - - return `당신은 대한민국 해양경찰청의 해양상황 분석 AI 어시스턴트입니다. -현재 실시간 해양 모니터링 데이터를 기반으로 분석을 제공합니다. - -## 현재 해양 상황 요약 -- 전체 선박: ${totalShipCount}척 -- 한국 선박: ${koreanShipCount}척 -- 중국 선박: ${chineseShipCount}척 -- 중국 어선: ${cnFishing.length}척 (조업 추정: ${cnFishingOperating.length}척) - -## 선박 유형별 현황 -${Object.entries(byType).map(([k, v]) => `- ${k}: ${v}척`).join('\n')} - -## 국적별 현황 (상위) -${Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([k, v]) => `- ${k}: ${v}척`).join('\n')} - -## 한중어업협정 핵심 -- 중국 허가어선 906척 (PT 저인망 323쌍, GN 유자망 200척, PS 위망 16척, OT 1척식 13척, FC 운반선 31척) -- 특정어업수역 I~IV에서만 조업 허가 -- 휴어기: PT/OT 4/16~10/15, GN 6/2~8/31 -- 다크베셀(AIS 차단) 감시 필수 - -## 응답 규칙 -- 한국어로 답변 -- 간결하고 분석적으로 -- 데이터 기반 답변 우선 -- 불법조업 의심 시 근거 제시 -- 필요시 조치 권고 포함`; -} - -export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShipCount }: Props) { const [isOpen, setIsOpen] = useState(true); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [elapsed, setElapsed] = useState(0); + const timerRef = useRef | null>(null); + const [expanded, setExpanded] = useState(false); + const [historyLoaded, setHistoryLoaded] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); + const abortRef = useRef(null); + + // 마운트 시 Redis에서 대화 히스토리 로드 + useEffect(() => { + if (historyLoaded) return; + fetch(`${AI_CHAT_URL}/history?user_id=${encodeURIComponent(userId)}`) + .then(res => res.ok ? res.json() : []) + .then((history: { role: string; content: string }[]) => { + if (history.length > 0) { + setMessages(history.map(m => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + timestamp: Date.now(), + }))); + } + }) + .catch(() => { /* Redis 미연결 시 무시 */ }) + .finally(() => setHistoryLoaded(true)); + }, [userId, historyLoaded]); + + useEffect(() => { + if (isLoading) { + setElapsed(0); + timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000); + } else if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + return () => { if (timerRef.current) clearInterval(timerRef.current); }; + }, [isLoading]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -85,72 +88,166 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi setInput(''); setIsLoading(true); - try { - const systemPrompt = buildSystemPrompt({ ships, koreanShipCount, chineseShipCount, totalShipCount }); - const apiMessages = [ - { role: 'system', content: systemPrompt }, - ...messages.filter(m => m.role !== 'system').map(m => ({ role: m.role, content: m.content })), - { role: 'user', content: userMsg.content }, - ]; + // 스트리밍 placeholder 추가 + const streamingMsg: ChatMessage = { role: 'assistant', content: '', timestamp: Date.now(), isStreaming: true }; + setMessages(prev => [...prev, streamingMsg]); + const controller = new AbortController(); + abortRef.current = controller; + + try { const res = await fetch(AI_CHAT_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'qwen2.5:7b', - messages: apiMessages, - stream: false, - options: { temperature: 0.3, num_predict: 1024 }, + message: userMsg.content, + user_id: userId, + stream: true, }), + signal: controller.signal, }); - if (!res.ok) throw new Error(`Ollama error: ${res.status}`); - const data = await res.json(); - const assistantMsg: ChatMessage = { - role: 'assistant', - content: data.message?.content || '응답을 생성할 수 없습니다.', - timestamp: Date.now(), - }; - setMessages(prev => [...prev, assistantMsg]); + if (!res.ok) throw new Error(`서버 오류: ${res.status}`); + if (!res.body) throw new Error('스트리밍 미지원'); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let accumulated = ''; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6); + if (data === '[DONE]') break; + + try { + const chunk = JSON.parse(data) as { content: string; done: boolean }; + accumulated += chunk.content; + + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { + ...updated[updated.length - 1], + content: accumulated, + }; + return updated; + }); + + if (chunk.done) break; + } catch { + // JSON 파싱 실패 무시 + } + } + } + + // 스트리밍 완료 + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { + ...updated[updated.length - 1], + isStreaming: false, + }; + return updated; + }); } catch (err) { - setMessages(prev => [...prev, { - role: 'assistant', - content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`, - timestamp: Date.now(), - }]); + if ((err as Error).name === 'AbortError') return; + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { + role: 'assistant', + content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}`, + timestamp: Date.now(), + isStreaming: false, + }; + return updated; + }); } finally { setIsLoading(false); + abortRef.current = null; } - }, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]); + }, [input, isLoading, userId]); + + const clearHistory = useCallback(async () => { + setMessages([]); + try { + await fetch(`${AI_CHAT_URL}/history?user_id=${encodeURIComponent(userId)}`, { method: 'DELETE' }); + } catch { /* 무시 */ } + }, [userId]); const quickQuestions = [ '현재 해양 상황을 요약해줘', '중국어선 불법조업 의심 분석해줘', - '서해 위험도를 평가해줘', + '위험 선박 상위 10척 알려줘', '다크베셀 현황 분석해줘', ]; return (
{/* Toggle header */}
setIsOpen(p => !p)} + onClick={() => { + if (!isOpen) { setIsOpen(true); return; } + if (expanded) { setExpanded(false); return; } + setExpanded(true); + }} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 8px', cursor: 'pointer', background: isOpen ? 'rgba(168,85,247,0.12)' : 'rgba(168,85,247,0.04)', - borderRadius: 4, - borderLeft: '2px solid rgba(168,85,247,0.5)', + borderRadius: expanded ? '8px 8px 0 0' : 4, + borderLeft: expanded ? 'none' : '2px solid rgba(168,85,247,0.5)', + flexShrink: 0, }} > 🤖 AI 해양분석 - Qwen 2.5 - - {isOpen ? '▼' : '▶'} + + Qwen3 14B + + + {isOpen && ( + + )} + {!isOpen && ( + + )}
@@ -158,10 +255,11 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi {isOpen && (
{/* Messages */}
)} - {messages.map((msg, i) => ( -
- {msg.content} -
- ))} - {isLoading && ( + {messages.map((msg, i) => { + const isAssistant = msg.role === 'assistant'; + const { thinking, answer } = isAssistant ? splitThinking(msg.content) : { thinking: '', answer: msg.content }; + const displayText = isAssistant ? (answer || (thinking && !msg.isStreaming ? '' : msg.content)) : msg.content; + + return ( +
+ {/* thinking 접기 블록 */} + {isAssistant && thinking && ( +
+ 도구 호출 +
{thinking}
+
+ )} + {/* 메시지 본문 */} +
+ {displayText} + {msg.isStreaming && msg.content && ( + + + + {String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')} + + + )} +
+
+ ); + })} + {isLoading && !messages[messages.length - 1]?.content && (
- 분석 중... + 분석 중 + + {String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')} +
)}
@@ -231,11 +374,24 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(0,0,0,0.15)', }}> + {messages.length > 0 && ( + + )} setInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }} + onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void sendMessage(); } }} placeholder="해양 상황 질문..." disabled={isLoading} style={{ @@ -246,7 +402,7 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi }} />