/** * 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(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 ( 📷 사진 장애 진단 장비/에러 화면을 촬영하면 AI가 자동 진단합니다. ⚠️ 화면에 비밀번호/내부 IP가 노출되지 않았는지 확인하세요. pick('camera')}> 촬영 pick('library')}> 앨범에서 선택 {uri ? : null} {loading ? ( AI가 사진을 분석 중입니다... ) : null} {result ? ( 진단 결과 {result} ) : null} {err ? {err} : null} ) } 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 }, })