guardia-messenger/components/PhotoDiagnosis.tsx

140 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PhotoDiagnosis (#19) — 사진 → Ollama llava → 장애 진단
*
* expo-image-picker로 장비/에러화면 사진을 촬영/선택 → base64 변환
* → generateWithImage('llava', ...) → 한국어 진단 결과 → onDiagnosis 콜백.
*
* 보안: 이미지는 온프레미스 Ollama(localhost:11434)로만 전송. 외부 API 미사용.
* 서버 전송 전 민감정보(비밀번호/IP 노출 화면 등) 검토 안내 표시.
*/
import { useState } from 'react'
import { View, Text, Pressable, StyleSheet, ActivityIndicator, Image } from 'react-native'
import { COLORS } from '../constants/Config'
import { generateWithImage, DEFAULT_VISION_MODEL } from '../lib/ollama'
// expo-image-picker 선택적 임포트 (미설치 환경 폴백)
let ImagePicker: any = null
try {
ImagePicker = require('expo-image-picker')
} catch {
/* 폴백 */
}
interface Props {
onDiagnosis: (text: string) => void
}
const DIAGNOSIS_PROMPT =
'당신은 IT 인프라 운영 전문가입니다. 이 사진은 서버/네트워크 장비 또는 시스템 화면입니다. ' +
'보이는 오류, 경고등, 에러 메시지, 이상 상태를 한국어로 진단하고 가능한 원인과 조치를 간결히 설명해 주세요. ' +
'추측이 필요하면 명시하세요.'
export function PhotoDiagnosis({ onDiagnosis }: Props) {
const [uri, setUri] = useState<string | null>(null)
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
async function pick(from: 'camera' | 'library') {
setErr('')
if (!ImagePicker) {
setErr('expo-image-picker 미설치 환경입니다.')
return
}
try {
const perm =
from === 'camera'
? await ImagePicker.requestCameraPermissionsAsync()
: await ImagePicker.requestMediaLibraryPermissionsAsync()
if (!perm.granted) {
setErr('카메라/사진 접근 권한이 필요합니다.')
return
}
const opts = { base64: true, quality: 0.6, allowsEditing: true }
const res =
from === 'camera'
? await ImagePicker.launchCameraAsync(opts)
: await ImagePicker.launchImageLibraryAsync(opts)
if (res.canceled || !res.assets?.[0]) return
const asset = res.assets[0]
setUri(asset.uri)
await diagnose(asset.base64 ?? '')
} catch {
setErr('사진을 불러오지 못했습니다.')
}
}
async function diagnose(base64: string) {
if (!base64) {
setErr('이미지 데이터를 읽지 못했습니다.')
return
}
setLoading(true)
setResult('')
const text = await generateWithImage(DEFAULT_VISION_MODEL, DIAGNOSIS_PROMPT, base64)
setLoading(false)
if (!text) {
setErr('AI 진단 서버(Ollama)에 연결할 수 없습니다.')
return
}
setResult(text)
onDiagnosis(text)
}
return (
<View style={S.wrap}>
<Text style={S.title}>📷 </Text>
<Text style={S.hint}>/ AI가 .</Text>
<Text style={S.warn}> / IP가 .</Text>
<View style={S.btnRow}>
<Pressable style={S.btn} onPress={() => pick('camera')}>
<Text style={S.btnText}></Text>
</Pressable>
<Pressable style={[S.btn, S.btnAlt]} onPress={() => pick('library')}>
<Text style={[S.btnText, S.btnAltText]}> </Text>
</Pressable>
</View>
{uri ? <Image source={{ uri }} style={S.preview} resizeMode="cover" /> : null}
{loading ? (
<View style={S.loadingBox}>
<ActivityIndicator color={COLORS.accent} />
<Text style={S.loadingText}>AI가 ...</Text>
</View>
) : null}
{result ? (
<View style={S.resultBox}>
<Text style={S.resultLabel}> </Text>
<Text style={S.resultText}>{result}</Text>
</View>
) : null}
{err ? <Text style={S.err}>{err}</Text> : null}
</View>
)
}
export default PhotoDiagnosis
const S = StyleSheet.create({
wrap: { backgroundColor: COLORS.card, borderRadius: 14, padding: 16, borderWidth: 1, borderColor: COLORS.border },
title: { fontSize: 15, fontWeight: '700', color: COLORS.text },
hint: { fontSize: 12, color: COLORS.muted, marginTop: 4 },
warn: { fontSize: 11, color: COLORS.warning, marginTop: 6 },
btnRow: { flexDirection: 'row', gap: 8, marginTop: 12 },
btn: { flex: 1, backgroundColor: COLORS.accent, borderRadius: 10, paddingVertical: 11, alignItems: 'center' },
btnAlt: { backgroundColor: COLORS.light },
btnText: { color: '#fff', fontWeight: '700', fontSize: 13 },
btnAltText: { color: COLORS.blue },
preview: { width: '100%', height: 180, borderRadius: 10, marginTop: 12, backgroundColor: '#eee' },
loadingBox: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 12 },
loadingText: { fontSize: 12, color: COLORS.muted },
resultBox: { marginTop: 12, backgroundColor: COLORS.light, borderRadius: 10, padding: 12 },
resultLabel: { fontSize: 12, fontWeight: '700', color: COLORS.blue, marginBottom: 6 },
resultText: { fontSize: 13, color: COLORS.text, lineHeight: 19 },
err: { fontSize: 12, color: COLORS.danger, marginTop: 10 },
})