guardia-messenger/app/(tabs)/chat.tsx

205 lines
9.2 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import {
View, Text, TextInput, TouchableOpacity, ScrollView,
StyleSheet, KeyboardAvoidingView, Platform, ActivityIndicator,
} from 'react-native'
import { router } from 'expo-router'
import { COLORS, API_BASE } from '../../constants/Config'
import { sendAIMessage } from '../../services/api'
import { useAuth } from '../../hooks/useAuth'
import { authFetch } from '../../utils/auth'
import LineIcon from '../../components/LineIcon'
import { VoiceInput } from '../../components/VoiceInput'
import { NextActions } from '../../components/NextActions'
import { generateJSON, DEFAULT_TEXT_MODEL } from '../../lib/ollama'
interface Msg { id: number; role: 'user' | 'ai'; text: string; time: string }
// Ollama가 자연어를 분류하는 명령 스키마
interface Command {
intent: 'query_server' | 'create_sr' | 'query_sr' | 'open_screen' | 'chat'
server_id?: string
screen?: string
reply?: string
}
const QUICK = ['서버 상태 확인', 'SR 목록 보여줘', '최근 인시던트', '긴급 SR 등록']
export default function ChatScreen() {
const { user } = useAuth()
const [msgs, setMsgs] = useState<Msg[]>([
{ id: 0, role: 'ai', text: `안녕하세요 ${user?.display_name ?? ''}님! 👋\nGUARDiA AI 어시스턴트입니다.\n자연어로 명령하시면 SR 등록·조회를 자동 실행합니다.`, time: now() },
])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [lastContext, setLastContext] = useState('AI 챗봇 대기 중')
const scrollRef = useRef<ScrollView>(null)
function now() { return new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) }
function pushAI(text: string) {
setMsgs(m => [...m, { id: Date.now() + Math.random(), role: 'ai', text, time: now() }])
}
// 자연어 → Ollama 의도 분류 → 명령 실행
async function classifyAndRun(text: string): Promise<string> {
const prompt =
`당신은 ITSM 운영 어시스턴트입니다. 운영자 입력: "${text}". ` +
`의도를 JSON으로만 분류하세요: ` +
`{"intent":"query_server|create_sr|query_sr|open_screen|chat",` +
`"server_id":"서버ID(있으면)","screen":"sr|dr|network|notifications(있으면)","reply":"chat일 때 한국어 답변"}`
const cmd = await generateJSON<Command>(DEFAULT_TEXT_MODEL, prompt, { intent: 'chat', reply: '' })
switch (cmd.intent) {
case 'query_server': {
if (!cmd.server_id) return '조회할 서버를 알려주세요. (예: "서버 001 상태")'
try {
const res = await authFetch(`${API_BASE}/api/servers/${encodeURIComponent(cmd.server_id)}`)
if (res.ok) {
const d = await res.json()
// 보안: ip/ssh/pw 등 민감 필드는 표시하지 않음
const name = d.name ?? d.hostname ?? cmd.server_id
const status = d.status ?? d.state ?? '알 수 없음'
return `🖥️ 서버 ${name}\n상태: ${status}${d.cpu ? `\nCPU: ${d.cpu}%` : ''}${d.mem ? `\n메모리: ${d.mem}%` : ''}`
}
return `서버 ${cmd.server_id} 정보를 가져오지 못했습니다.`
} catch {
return '서버 조회 중 오류가 발생했습니다.'
}
}
case 'create_sr':
setTimeout(() => router.push('/sr'), 400)
return '📋 SR 등록 화면을 엽니다.'
case 'query_sr':
setTimeout(() => router.push('/sr'), 400)
return '📋 SR 목록 화면으로 이동합니다.'
case 'open_screen': {
const screen = cmd.screen ?? 'index'
setTimeout(() => router.push(`/${screen}` as any), 400)
return `화면(${screen})으로 이동합니다.`
}
default:
return cmd.reply || ''
}
}
const send = async (text = input) => {
if (!text.trim() || loading) return
const trimmed = text.trim()
setMsgs(m => [...m, { id: Date.now(), role: 'user', text: trimmed, time: now() }])
setInput('')
setLoading(true)
setLastContext(`사용자 요청: ${trimmed}`)
try {
// 1) Ollama 의도 분류 + 명령 실행
const local = await classifyAndRun(trimmed)
if (local) {
pushAI(local)
} else {
// 2) 폴백: ITSM 챗봇 API
const r = await sendAIMessage(trimmed)
const reply = r.data?.reply ?? r.data?.message ?? r.data?.response ?? '응답을 받았습니다.'
pushAI(reply)
}
} catch {
pushAI('현재 AI 서버에 연결할 수 없습니다. Ollama 서버 상태를 확인해주세요.')
} finally {
setLoading(false)
}
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100)
}
useEffect(() => {
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: false }), 50)
}, [msgs])
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={s.container}>
<ScrollView ref={scrollRef} style={s.messages} contentContainerStyle={{ padding: 16 }}>
{msgs.map(m => (
<View key={m.id} style={[s.msgRow, m.role === 'user' && s.userRow]}>
{m.role === 'ai' && (
<View style={[s.avatar, { backgroundColor: 'rgba(0,160,200,.12)', borderRadius: 20 }]}>
<LineIcon name="ai" size={20} color={COLORS.accent} />
</View>
)}
<View style={[s.bubble, m.role === 'user' ? s.userBubble : s.aiBubble]}>
<Text style={[s.bubbleText, m.role === 'user' && s.userText]}>{m.text}</Text>
<Text style={[s.timeText, m.role === 'user' && { color: 'rgba(255,255,255,.6)' }]}>{m.time}</Text>
</View>
</View>
))}
{loading && (
<View style={s.msgRow}>
<View style={[s.avatar, { backgroundColor: 'rgba(0,160,200,.12)', borderRadius: 20 }]}>
<LineIcon name="ai" size={20} color={COLORS.accent} />
</View>
<View style={s.aiBubble}>
<ActivityIndicator size="small" color={COLORS.accent} />
</View>
</View>
)}
{/* #20 다음 명령 제안 */}
{!loading && (
<View style={{ marginTop: 8 }}>
<NextActions context={lastContext} onSelect={a => send(a)} />
</View>
)}
</ScrollView>
<ScrollView horizontal showsHorizontalScrollIndicator={false}
style={s.quickScroll} contentContainerStyle={{ paddingHorizontal: 12, gap: 8 }}>
{QUICK.map(q => (
<TouchableOpacity key={q} style={s.quickChip} onPress={() => send(q)}>
<Text style={s.quickChipText}>{q}</Text>
</TouchableOpacity>
))}
</ScrollView>
<View style={s.inputRow}>
{/* #25 음성 입력 → 입력창 자동 전달 (ko-KR) */}
<VoiceInput onTranscript={t => { if (t) setInput(t) }} size="small" />
<TextInput
style={s.textInput}
value={input}
onChangeText={setInput}
placeholder="GUARDiA AI에게 명령하세요..."
placeholderTextColor={COLORS.muted}
multiline
maxLength={500}
returnKeyType="send"
onSubmitEditing={() => send()}
/>
<TouchableOpacity style={[s.sendBtn, (!input.trim() || loading) && s.sendDisabled]}
onPress={() => send()} disabled={!input.trim() || loading}>
<LineIcon name="send" size={18} color="#fff" />
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
messages: { flex: 1 },
msgRow: { flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12, gap: 8 },
userRow: { flexDirection: 'row-reverse' },
avatar: { width: 38, height: 38, alignItems: 'center', justifyContent: 'center', marginBottom: 4 },
bubble: { maxWidth: '75%', borderRadius: 16, padding: 12 },
aiBubble: { backgroundColor: '#fff', borderBottomLeftRadius: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: .06, elevation: 1 },
userBubble: { backgroundColor: COLORS.accent, borderBottomRightRadius: 4 },
bubbleText: { fontSize: 14, color: COLORS.text, lineHeight: 20 },
userText: { color: '#fff' },
timeText: { fontSize: 10, color: COLORS.muted, marginTop: 4, textAlign: 'right' },
quickScroll: { maxHeight: 44, borderTopWidth: 1, borderTopColor: COLORS.border, backgroundColor: '#fff' },
quickChip: { alignSelf: 'center', backgroundColor: COLORS.light, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20 },
quickChipText: { fontSize: 12, color: COLORS.accent, fontWeight: '500' },
inputRow: { flexDirection: 'row', padding: 12, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: COLORS.border, alignItems: 'flex-end', gap: 8 },
textInput: { flex: 1, borderWidth: 1.5, borderColor: COLORS.border, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 14, color: COLORS.text, maxHeight: 100, backgroundColor: '#fafafa' },
sendBtn: { width: 42, height: 42, borderRadius: 21, backgroundColor: COLORS.accent, justifyContent: 'center', alignItems: 'center' },
sendDisabled: { opacity: .4 },
})