112 lines
5.8 KiB
TypeScript
112 lines
5.8 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Switch } from 'react-native';
|
|
|
|
const MODELS = [
|
|
{ id: 'llama3', name: 'Llama 3 8B', size: '4.7GB', type: '범용', available: true },
|
|
{ id: 'codellama', name: 'CodeLlama 7B', size: '3.8GB', type: '코드', available: true },
|
|
{ id: 'nomic-embed', name: 'Nomic Embed', size: '0.3GB', type: '임베딩', available: true },
|
|
{ id: 'llava', name: 'LLaVA 7B', size: '4.1GB', type: '비전', available: false },
|
|
];
|
|
|
|
export default function OnDeviceAIScreen() {
|
|
const [query, setQuery] = useState('');
|
|
const [result, setResult] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedModel, setSelectedModel] = useState('llama3');
|
|
const [offlineMode, setOfflineMode] = useState(false);
|
|
const [stats, setStats] = useState({ requests: 0, avg_ms: 0, cache_hits: 0 });
|
|
|
|
const runQuery = async () => {
|
|
if (!query.trim()) return;
|
|
setLoading(true);
|
|
const start = Date.now();
|
|
try {
|
|
const r = await fetch('http://localhost:11434/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: selectedModel, prompt: query, stream: false }),
|
|
});
|
|
if (r.ok) {
|
|
const data = await r.json();
|
|
setResult(data.response || '응답 없음');
|
|
const elapsed = Date.now() - start;
|
|
setStats(prev => ({ requests: prev.requests + 1, avg_ms: Math.round((prev.avg_ms * prev.requests + elapsed) / (prev.requests + 1)), cache_hits: prev.cache_hits }));
|
|
}
|
|
} catch {
|
|
setResult(offlineMode ? '[오프라인] 캐시된 응답을 사용합니다.\n\n이 기기는 온디바이스 AI 추론 모드로 동작 중입니다.' : 'Ollama 서버에 연결할 수 없습니다.');
|
|
} finally { setLoading(false); }
|
|
};
|
|
|
|
return (
|
|
<ScrollView style={s.container}>
|
|
<Text style={s.title}>온디바이스 AI</Text>
|
|
<Text style={s.sub}>Ollama 온프레미스 모델 직접 실행</Text>
|
|
|
|
<View style={s.card}>
|
|
<View style={s.row}><Text style={s.label}>오프라인 모드</Text>
|
|
<Switch value={offlineMode} onValueChange={setOfflineMode} trackColor={{ true: '#00A0C8', false: '#333' }} />
|
|
</View>
|
|
<View style={s.statsRow}>
|
|
<View style={s.stat}><Text style={s.statVal}>{stats.requests}</Text><Text style={s.statLbl}>총 요청</Text></View>
|
|
<View style={s.stat}><Text style={s.statVal}>{stats.avg_ms}ms</Text><Text style={s.statLbl}>평균 응답</Text></View>
|
|
<View style={s.stat}><Text style={s.statVal}>{stats.cache_hits}</Text><Text style={s.statLbl}>캐시 히트</Text></View>
|
|
</View>
|
|
</View>
|
|
|
|
<Text style={s.sectionTitle}>모델 선택</Text>
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 16 }}>
|
|
{MODELS.map(m => (
|
|
<TouchableOpacity key={m.id} disabled={!m.available}
|
|
style={[s.modelChip, selectedModel === m.id && s.modelActive, !m.available && s.modelDisabled]}
|
|
onPress={() => setSelectedModel(m.id)}>
|
|
<Text style={s.modelName}>{m.name}</Text>
|
|
<Text style={s.modelMeta}>{m.type} · {m.size}</Text>
|
|
{!m.available && <Text style={s.modelStatus}>다운로드 필요</Text>}
|
|
</TouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
|
|
<Text style={s.sectionTitle}>쿼리</Text>
|
|
<TextInput style={s.input} value={query} onChangeText={setQuery}
|
|
placeholder="질문을 입력하세요..." placeholderTextColor="#555"
|
|
multiline numberOfLines={3} textAlignVertical="top" />
|
|
<TouchableOpacity style={s.runBtn} onPress={runQuery} disabled={loading}>
|
|
<Text style={s.runBtnText}>{loading ? '처리 중...' : '🤖 실행'}</Text>
|
|
</TouchableOpacity>
|
|
|
|
{result ? (
|
|
<View style={s.resultBox}>
|
|
<Text style={s.resultLabel}>응답</Text>
|
|
<Text style={s.resultText}>{result}</Text>
|
|
</View>
|
|
) : null}
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
|
|
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
|
|
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
|
|
card: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
|
|
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
|
label: { color: '#fff', fontSize: 15 },
|
|
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
|
|
stat: { alignItems: 'center' },
|
|
statVal: { color: '#00A0C8', fontSize: 18, fontWeight: '700' },
|
|
statLbl: { color: '#888', fontSize: 11 },
|
|
sectionTitle: { color: '#fff', fontSize: 15, fontWeight: '600', marginBottom: 10 },
|
|
modelChip: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 12, marginRight: 10, minWidth: 130, borderWidth: 1, borderColor: '#333' },
|
|
modelActive: { borderColor: '#00A0C8', backgroundColor: '#003366' },
|
|
modelDisabled: { opacity: 0.5 },
|
|
modelName: { color: '#fff', fontWeight: '600', marginBottom: 2 },
|
|
modelMeta: { color: '#888', fontSize: 11 },
|
|
modelStatus: { color: '#ff8800', fontSize: 11, marginTop: 4 },
|
|
input: { backgroundColor: '#1A1F2E', color: '#fff', borderRadius: 12, padding: 14, marginBottom: 12, minHeight: 80, borderWidth: 1, borderColor: '#333' },
|
|
runBtn: { backgroundColor: '#00A0C8', padding: 14, borderRadius: 12, alignItems: 'center', marginBottom: 16 },
|
|
runBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
|
resultBox: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: '#003366' },
|
|
resultLabel: { color: '#00A0C8', fontWeight: '600', marginBottom: 8 },
|
|
resultText: { color: '#fff', lineHeight: 22 },
|
|
});
|