205 lines
9.2 KiB
TypeScript
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 },
|
|
})
|