kcg-ai-monitoring/frontend/src/features/ai-operations/AIAssistant.tsx
htlee c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:

**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입

**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역

**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)

**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)

**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭

**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup

**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지

**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`

**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
2026-04-16 17:09:14 +09:00

168 lines
7.4 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react';
import { sendChatMessage } from '@/services/chatApi';
/* SFR-20: 자연어 처리 기반 AI 의사결정 지원(Q&A) 서비스 */
interface Message { role: 'user' | 'assistant'; content: string; refs?: string[]; }
const SAMPLE_CONVERSATIONS = [
{ id: '1', title: '서해 NLL 침범 대응 절차', time: '08:45' },
{ id: '2', title: '중국어선 환적 판별 기준', time: '08:20' },
{ id: '3', title: '조업 금지 기간 법령 조회', time: '어제' },
{ id: '4', title: 'MMSI 변조 선박 처리 방안', time: '04-02' },
];
const INITIAL_MESSAGES: Message[] = [
{ role: 'user', content: '서해 NLL 인근에서 중국어선이 EEZ를 침범한 경우 단속 절차는 어떻게 되나요?' },
{ role: 'assistant', content: `EEZ 침범 중국어선에 대한 단속 절차는 다음과 같습니다:
**1단계: 탐지·식별**
- AI 시스템에서 EEZ 침범 자동 탐지 (SFR-09 패턴탐지)
- AIS/레이더를 통한 선박 식별 (MMSI, 선명, 국적)
**2단계: 경보·출동**
- 상황실 즉시 경보 발령 (SFR-17 AI 알림)
- 인근 함정 출동 지시 (SFR-07 경로 추천)
**3단계: 정선·검문**
- VHF 16채널 정선 명령 (한·중·영)
- 임검반 승선 검문 (선박서류, 어획물, 어구)
**4단계: 조치**
- 위반 확인 시: 나포 → 인천/목포 호송 → 담보금 부과
- 경미 위반: 경고 조치 후 퇴거 명령
**관련 법령:**
- 배타적경제수역법 제5조 (외국인 어업 제한)
- 한중어업협정 제6조 (특정수역 관리)`, refs: ['배타적경제수역법 제5조', '한중어업협정 제6조', 'SFR-09 패턴탐지', 'SFR-17 AI알림'] },
];
export function AIAssistant() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1');
const handleSend = async () => {
if (!input.trim()) return;
const userMsg = input;
setMessages((prev) => [...prev, { role: 'user', content: userMsg }]);
setInput('');
// 백엔드 prediction chat 프록시 호출
setMessages((prev) => [...prev, { role: 'assistant', content: '질의를 분석 중입니다...', refs: [] }]);
try {
const res = await sendChatMessage(userMsg);
const reply = res.ok
? (res.reply ?? '응답 없음')
: (res.message ?? 'Prediction 채팅 미연결');
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', content: reply, refs: [] };
return next;
});
} catch (e) {
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', content: '에러: ' + (e instanceof Error ? e.message : 'unknown'), refs: [] };
return next;
});
}
};
return (
<PageContainer className="h-full flex flex-col">
<PageHeader
icon={MessageSquare}
iconColor="text-green-600 dark:text-green-400"
title={t('assistant.title')}
description={t('assistant.desc')}
/>
<div className="flex-1 flex gap-3 min-h-0">
{/* 대화 이력 사이드바 */}
<Card className="w-56 shrink-0 bg-surface-raised border-border">
<CardContent className="p-3">
<div className="text-[11px] font-bold text-label mb-2"> </div>
<div className="space-y-1">
{SAMPLE_CONVERSATIONS.map(c => (
<div key={c.id} onClick={() => setSelectedConv(c.id)}
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-600 dark:text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
<div className="truncate">{c.title}</div>
<div className="text-[8px] text-hint mt-0.5">{c.time}</div>
</div>
))}
</div>
<div className="mt-3 pt-2 border-t border-border">
<div className="text-[9px] text-hint flex items-center gap-1"><Shield className="w-3 h-3" /> </div>
<div className="text-[9px] text-hint flex items-center gap-1 mt-1"><BookOpen className="w-3 h-3" /> DB 2,345 </div>
</div>
</CardContent>
</Card>
{/* 채팅 영역 */}
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto space-y-3 mb-3">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
)}
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
msg.role === 'user'
? 'bg-blue-600/20 border border-blue-500/20'
: 'bg-surface-overlay border border-border'
}`}>
<div className="text-[11px] text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
{msg.refs && msg.refs.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
{msg.refs.map(r => (
<Badge key={r} className="bg-green-500/10 text-green-600 dark:text-green-400 border-0 text-[8px] flex items-center gap-0.5">
<FileText className="w-2.5 h-2.5" />{r}
</Badge>
))}
</div>
)}
</div>
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
)}
</div>
))}
</div>
{/* 입력창 */}
<div className="flex gap-2 shrink-0">
<Input
aria-label="AI 어시스턴트 질의"
size="md"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
className="flex-1"
/>
<Button
variant="primary"
size="md"
onClick={handleSend}
aria-label={tc('aria.send')}
icon={<Send className="w-4 h-4" />}
/>
</div>
</div>
</div>
</PageContainer>
);
}