140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
/**
|
||
* 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 },
|
||
})
|