- AI 해양분석 챗패널 추가 (AiChatPanel, Ollama/Qwen 2.5:7b) - 시스템 프롬프트에 실시간 선박 데이터 자동 주입 - 보라/퍼플 톤 UI 차별화 - Vite 프록시 /ollama 추가 - 이란 발전소 20→29개 확장 (Wikipedia 기반 좌표/용량 보정) - 선박 현황 폰트 사이즈 축소 (11→9px, 13→10px) - OSINT LIVE 3개, 재난뉴스 2개 표시 + 스크롤 - 한국/중국 선박현황, 조업분석 기본 접힘 - AI 해양분석 기본 펼침 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
265 lines
9.6 KiB
TypeScript
265 lines
9.6 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import type { Ship } from '../../types';
|
|
|
|
interface ChatMessage {
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface Props {
|
|
ships: Ship[];
|
|
koreanShipCount: number;
|
|
chineseShipCount: number;
|
|
totalShipCount: number;
|
|
}
|
|
|
|
const OLLAMA_URL = '/ollama/api/chat';
|
|
|
|
function buildSystemPrompt(props: Props): string {
|
|
const { ships, koreanShipCount, chineseShipCount, totalShipCount } = props;
|
|
|
|
// 선박 유형별 통계
|
|
const byType: Record<string, number> = {};
|
|
const byFlag: Record<string, number> = {};
|
|
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<ChatMessage[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) inputRef.current?.focus();
|
|
}, [isOpen]);
|
|
|
|
const sendMessage = useCallback(async () => {
|
|
if (!input.trim() || isLoading) return;
|
|
|
|
const userMsg: ChatMessage = { role: 'user', content: input.trim(), timestamp: Date.now() };
|
|
setMessages(prev => [...prev, userMsg]);
|
|
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 },
|
|
];
|
|
|
|
const res = await fetch(OLLAMA_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 },
|
|
}),
|
|
});
|
|
|
|
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]);
|
|
} catch (err) {
|
|
setMessages(prev => [...prev, {
|
|
role: 'assistant',
|
|
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`,
|
|
timestamp: Date.now(),
|
|
}]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]);
|
|
|
|
const quickQuestions = [
|
|
'현재 해양 상황을 요약해줘',
|
|
'중국어선 불법조업 의심 분석해줘',
|
|
'서해 위험도를 평가해줘',
|
|
'다크베셀 현황 분석해줘',
|
|
];
|
|
|
|
return (
|
|
<div style={{
|
|
borderTop: '1px solid rgba(168,85,247,0.2)',
|
|
marginTop: 8,
|
|
}}>
|
|
{/* Toggle header */}
|
|
<div
|
|
onClick={() => setIsOpen(p => !p)}
|
|
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)',
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 12 }}>🤖</span>
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#c4b5fd' }}>AI 해양분석</span>
|
|
<span style={{ fontSize: 8, color: '#8b5cf6', marginLeft: 2, background: 'rgba(139,92,246,0.15)', padding: '1px 5px', borderRadius: 3 }}>Qwen 2.5</span>
|
|
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#8b5cf6' }}>
|
|
{isOpen ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Chat body */}
|
|
{isOpen && (
|
|
<div style={{
|
|
display: 'flex', flexDirection: 'column',
|
|
height: 360, background: 'rgba(88,28,135,0.08)',
|
|
borderRadius: '0 0 6px 6px', overflow: 'hidden',
|
|
borderLeft: '2px solid rgba(168,85,247,0.3)',
|
|
borderBottom: '1px solid rgba(168,85,247,0.15)',
|
|
}}>
|
|
{/* Messages */}
|
|
<div style={{
|
|
flex: 1, overflowY: 'auto', padding: '6px 8px',
|
|
display: 'flex', flexDirection: 'column', gap: 6,
|
|
}}>
|
|
{messages.length === 0 && (
|
|
<div style={{ padding: '12px 0', textAlign: 'center' }}>
|
|
<div style={{ fontSize: 10, color: '#a78bfa', marginBottom: 8 }}>
|
|
해양 상황에 대해 질문하세요
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{quickQuestions.map((q, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => { setInput(q); }}
|
|
style={{
|
|
background: 'rgba(139,92,246,0.08)',
|
|
border: '1px solid rgba(139,92,246,0.25)',
|
|
borderRadius: 4, padding: '4px 8px',
|
|
fontSize: 9, color: '#a78bfa',
|
|
cursor: 'pointer', textAlign: 'left',
|
|
}}
|
|
>
|
|
{q}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
|
maxWidth: '85%',
|
|
background: msg.role === 'user'
|
|
? 'rgba(139,92,246,0.25)'
|
|
: 'rgba(168,85,247,0.08)',
|
|
borderRadius: msg.role === 'user' ? '8px 8px 2px 8px' : '8px 8px 8px 2px',
|
|
padding: '6px 8px',
|
|
fontSize: 10,
|
|
color: '#e2e8f0',
|
|
lineHeight: 1.5,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
}}
|
|
>
|
|
{msg.content}
|
|
</div>
|
|
))}
|
|
{isLoading && (
|
|
<div style={{
|
|
alignSelf: 'flex-start', padding: '6px 8px',
|
|
background: 'rgba(168,85,247,0.08)', borderRadius: 8,
|
|
fontSize: 10, color: '#a78bfa',
|
|
}}>
|
|
분석 중...
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div style={{
|
|
display: 'flex', gap: 4, padding: '6px 8px',
|
|
borderTop: '1px solid rgba(255,255,255,0.06)',
|
|
background: 'rgba(0,0,0,0.15)',
|
|
}}>
|
|
<input
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
|
|
placeholder="해양 상황 질문..."
|
|
disabled={isLoading}
|
|
style={{
|
|
flex: 1, background: 'rgba(139,92,246,0.06)',
|
|
border: '1px solid rgba(139,92,246,0.2)',
|
|
borderRadius: 4, padding: '5px 8px',
|
|
fontSize: 10, color: '#e2e8f0', outline: 'none',
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={sendMessage}
|
|
disabled={isLoading || !input.trim()}
|
|
style={{
|
|
background: isLoading || !input.trim() ? '#334155' : '#7c3aed',
|
|
border: 'none', borderRadius: 4,
|
|
padding: '4px 10px', fontSize: 10, fontWeight: 700,
|
|
color: '#fff', cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
}}
|
|
>
|
|
전송
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|