sync: update from workspace (latest ITSM/CICD/DR changes)
This commit is contained in:
parent
43a65b5015
commit
b60a19ada2
@ -91,6 +91,13 @@ export default function TabLayout() {
|
||||
tabBarIcon: ({ focused }) => <TabIcon icon="🧠" label="AI" focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="voice"
|
||||
options={{
|
||||
title: '음성 명령',
|
||||
tabBarIcon: ({ focused }) => <TabIcon icon="🎤" label="음성" focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scan"
|
||||
options={{
|
||||
|
||||
183
app/(tabs)/voice.tsx
Normal file
183
app/(tabs)/voice.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import {
|
||||
View, Text, Pressable, StyleSheet, ScrollView,
|
||||
ActivityIndicator, Alert,
|
||||
} from 'react-native'
|
||||
import { COLORS, API_BASE } from '../../constants/Config'
|
||||
import { getToken } from '../../utils/auth'
|
||||
import { VoiceInput } from '../../components/VoiceInput'
|
||||
|
||||
// 음성 명령 → ITSM 매핑
|
||||
const VOICE_SHORTCUTS = [
|
||||
{ label: '서버 상태', command: '/server status', icon: '🖥️' },
|
||||
{ label: 'SR 만들기', command: '/sr create', icon: '📋' },
|
||||
{ label: '배포 시작', command: '/deploy', icon: '🚀' },
|
||||
{ label: '장애 보고', command: '/incident create',icon: '🚨' },
|
||||
{ label: '대시보드', command: '/dashboard', icon: '📊' },
|
||||
{ label: '경보 목록', command: '/alert list', icon: '🔔' },
|
||||
]
|
||||
|
||||
export default function VoiceTab() {
|
||||
const [isListening, setListening] = useState(false)
|
||||
const [transcript, setTranscript] = useState('')
|
||||
const [result, setResult] = useState<{ cmd: string; response: string } | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [history, setHistory] = useState<{ text: string; cmd: string; time: string }[]>([])
|
||||
|
||||
async function processVoice(text: string) {
|
||||
if (!text.trim()) return
|
||||
setTranscript(text)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = await getToken()
|
||||
const res = await fetch(`${API_BASE}/api/ux/voice-process`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, context: 'voice-tab' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.mapped_command) {
|
||||
setResult({ cmd: data.mapped_command, response: `실행: ${data.mapped_command}` })
|
||||
setHistory(prev => [{
|
||||
text, cmd: data.mapped_command,
|
||||
time: new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }),
|
||||
}, ...prev.slice(0, 9)])
|
||||
} else {
|
||||
setResult({ cmd: '', response: '명령을 인식하지 못했습니다. 다시 시도하거나 아래 빠른 명령을 사용하세요.' })
|
||||
}
|
||||
} catch (e) {
|
||||
setResult({ cmd: '', response: '서버 연결 오류' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runShortcut(command: string, label: string) {
|
||||
setTranscript(label)
|
||||
setLoading(true)
|
||||
setResult({ cmd: command, response: `실행 중: ${command}` })
|
||||
setHistory(prev => [{
|
||||
text: label, cmd: command,
|
||||
time: new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }),
|
||||
}, ...prev.slice(0, 9)])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={S.root} contentContainerStyle={{ paddingBottom: 40 }}>
|
||||
{/* 헤더 */}
|
||||
<View style={S.header}>
|
||||
<Text style={S.title}>🎤 음성 명령</Text>
|
||||
<Text style={S.subtitle}>한국어로 말하거나 아래 빠른 명령을 탭하세요</Text>
|
||||
</View>
|
||||
|
||||
{/* 메인 마이크 버튼 */}
|
||||
<View style={S.micSection}>
|
||||
<View style={S.micWrap}>
|
||||
<VoiceInput onTranscript={processVoice} size="normal" />
|
||||
</View>
|
||||
<Text style={S.micHint}>
|
||||
{isListening ? '듣고 있어요... 명령을 말씀하세요' : '마이크를 탭하여 시작'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 인식 결과 */}
|
||||
{loading && (
|
||||
<View style={S.resultBox}>
|
||||
<ActivityIndicator color={COLORS.gnbBg} />
|
||||
<Text style={{ marginTop: 8, color: '#64748b' }}>처리 중...</Text>
|
||||
</View>
|
||||
)}
|
||||
{!loading && result && (
|
||||
<View style={[S.resultBox, result.cmd ? S.resultSuccess : S.resultFail]}>
|
||||
{transcript ? <Text style={S.transcriptText}>💬 "{transcript}"</Text> : null}
|
||||
{result.cmd ? (
|
||||
<View style={S.cmdBadge}>
|
||||
<Text style={S.cmdText}>{result.cmd}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={S.responseText}>{result.response}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 빠른 명령 버튼 */}
|
||||
<Text style={S.sectionTitle}>⚡ 빠른 명령</Text>
|
||||
<View style={S.shortcuts}>
|
||||
{VOICE_SHORTCUTS.map((s, i) => (
|
||||
<Pressable key={i} onPress={() => runShortcut(s.command, s.label)} style={S.shortBtn}>
|
||||
<Text style={S.shortIcon}>{s.icon}</Text>
|
||||
<Text style={S.shortLabel}>{s.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 사용 이력 */}
|
||||
{history.length > 0 && (
|
||||
<>
|
||||
<Text style={S.sectionTitle}>📋 최근 명령</Text>
|
||||
<View style={S.historyBox}>
|
||||
{history.map((h, i) => (
|
||||
<View key={i} style={S.historyRow}>
|
||||
<Text style={S.historyTime}>{h.time}</Text>
|
||||
<Text style={S.historyText} numberOfLines={1}>{h.text}</Text>
|
||||
<Text style={S.historyCmd}>{h.cmd}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 도움말 */}
|
||||
<View style={S.helpBox}>
|
||||
<Text style={S.helpTitle}>💡 음성 명령 예시</Text>
|
||||
{['"서버 상태 확인해줘"', '"SR 만들어줘"', '"배포 시작해"', '"장애 보고해줘"'].map((e, i) => (
|
||||
<Text key={i} style={S.helpItem}>• {e}</Text>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const S = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#f8fafc' },
|
||||
header: { padding: 20, paddingBottom: 12, backgroundColor: COLORS.gnbBg },
|
||||
title: { fontSize: 20, fontWeight: '800', color: '#fff' },
|
||||
subtitle: { fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 4 },
|
||||
micSection: { alignItems: 'center', paddingVertical: 32, backgroundColor: '#fff',
|
||||
borderBottomWidth: 1, borderBottomColor: COLORS.border },
|
||||
micWrap: { width: 80, height: 80, borderRadius: 40, backgroundColor: '#eff6ff',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
shadowColor: COLORS.gnbBg, shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2, shadowRadius: 12, elevation: 6 },
|
||||
micHint: { marginTop: 12, fontSize: 13, color: '#64748b' },
|
||||
resultBox: { margin: 12, borderRadius: 12, padding: 16, alignItems: 'center' },
|
||||
resultSuccess:{ backgroundColor: '#eff6ff', borderWidth: 1, borderColor: '#bfdbfe' },
|
||||
resultFail: { backgroundColor: '#fff5f5', borderWidth: 1, borderColor: '#fca5a5' },
|
||||
transcriptText:{ fontSize: 13, color: '#64748b', marginBottom: 8, fontStyle: 'italic' },
|
||||
cmdBadge: { backgroundColor: COLORS.gnbBg, borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 6, marginBottom: 8 },
|
||||
cmdText: { color: '#fff', fontWeight: '700', fontSize: 14, fontFamily: 'monospace' },
|
||||
responseText: { fontSize: 13, color: '#374151', textAlign: 'center' },
|
||||
sectionTitle: { fontSize: 14, fontWeight: '700', color: '#374151',
|
||||
paddingHorizontal: 16, paddingVertical: 12 },
|
||||
shortcuts: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8 },
|
||||
shortBtn: { width: '30%', backgroundColor: '#fff', borderRadius: 12, padding: 12,
|
||||
alignItems: 'center', borderWidth: 1, borderColor: COLORS.border,
|
||||
shadowColor: '#000', shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05, shadowRadius: 3, elevation: 1 },
|
||||
shortIcon: { fontSize: 24, marginBottom: 4 },
|
||||
shortLabel: { fontSize: 12, fontWeight: '600', color: COLORS.gnbBg, textAlign: 'center' },
|
||||
historyBox: { marginHorizontal: 12, backgroundColor: '#fff', borderRadius: 12,
|
||||
borderWidth: 1, borderColor: COLORS.border, overflow: 'hidden' },
|
||||
historyRow: { flexDirection: 'row', alignItems: 'center', padding: 10,
|
||||
borderBottomWidth: 1, borderBottomColor: '#f1f5f9' },
|
||||
historyTime: { fontSize: 11, color: '#94a3b8', width: 40 },
|
||||
historyText: { flex: 1, fontSize: 12, color: '#374151', marginHorizontal: 8 },
|
||||
historyCmd: { fontSize: 11, color: COLORS.gnbBg, fontFamily: 'monospace' },
|
||||
helpBox: { margin: 12, backgroundColor: '#fff', borderRadius: 12, padding: 16,
|
||||
borderWidth: 1, borderColor: COLORS.border },
|
||||
helpTitle: { fontSize: 13, fontWeight: '700', color: '#374151', marginBottom: 8 },
|
||||
helpItem: { fontSize: 12, color: '#64748b', marginBottom: 4 },
|
||||
})
|
||||
88
components/SuggestedCommands.tsx
Normal file
88
components/SuggestedCommands.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { ScrollView, Text, Pressable, StyleSheet, View } from 'react-native'
|
||||
import { COLORS, API_BASE } from '../constants/Config'
|
||||
import { getToken } from '../utils/auth'
|
||||
|
||||
interface Props {
|
||||
recentMessages: string[]
|
||||
room?: string
|
||||
onSelect: (cmd: string) => void
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CMDS = ['/sr create', '/server status', '/dashboard', '/deploy', '/alert list']
|
||||
|
||||
export function SuggestedCommands({ recentMessages, room = 'general', onSelect, visible = true }: Props) {
|
||||
const [suggestions, setSuggestions] = useState<string[]>(DEFAULT_CMDS)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
if (recentMessages.length === 0) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const token = await getToken()
|
||||
const res = await fetch(`${API_BASE}/api/ux/next-commands`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recent_messages: recentMessages.slice(-5),
|
||||
context: room,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.commands?.length) setSuggestions(data.commands)
|
||||
}
|
||||
} catch {
|
||||
// 네트워크 오류 시 기본 명령 유지
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [recentMessages.length, room])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(fetchSuggestions, 600) // 디바운스
|
||||
return () => clearTimeout(timer)
|
||||
}, [fetchSuggestions])
|
||||
|
||||
async function handleSelect(cmd: string) {
|
||||
onSelect(cmd)
|
||||
// 명령 학습 API 호출
|
||||
try {
|
||||
const token = await getToken()
|
||||
await fetch(`${API_BASE}/api/ux/learn`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: cmd, room }),
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<View style={S.container}>
|
||||
<Text style={S.label}>추천 명령</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={S.scroll}>
|
||||
{suggestions.map((cmd, i) => (
|
||||
<Pressable key={i} onPress={() => handleSelect(cmd)} style={S.chip}>
|
||||
<Text style={S.chipText}>{cmd}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const S = StyleSheet.create({
|
||||
container: { paddingVertical: 6, paddingHorizontal: 12,
|
||||
borderTopWidth: 1, borderTopColor: COLORS.border,
|
||||
backgroundColor: '#f8fafc' },
|
||||
label: { fontSize: 10, color: COLORS.muted, marginBottom: 4 },
|
||||
scroll: { flexDirection: 'row' },
|
||||
chip: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16,
|
||||
backgroundColor: '#e0e7ff', marginRight: 6,
|
||||
borderWidth: 1, borderColor: '#c7d2fe' },
|
||||
chipText: { fontSize: 12, color: COLORS.gnbBg, fontWeight: '600',
|
||||
fontFamily: 'monospace' },
|
||||
})
|
||||
111
components/VoiceInput.tsx
Normal file
111
components/VoiceInput.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { View, Text, Pressable, StyleSheet, ActivityIndicator } from 'react-native'
|
||||
import { COLORS } from '../constants/Config'
|
||||
|
||||
// expo-speech-recognition 선택적 임포트
|
||||
let ExpoSpeechRecognitionModule: any = null
|
||||
let useSpeechRecognitionEvent: any = null
|
||||
try {
|
||||
const mod = require('@jamsch/expo-speech-recognition')
|
||||
ExpoSpeechRecognitionModule = mod.ExpoSpeechRecognitionModule
|
||||
useSpeechRecognitionEvent = mod.useSpeechRecognitionEvent
|
||||
} catch {
|
||||
// 패키지 미설치 시 폴백
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onTranscript: (text: string) => void
|
||||
size?: 'small' | 'normal'
|
||||
}
|
||||
|
||||
export function VoiceInput({ onTranscript, size = 'normal' }: Props) {
|
||||
const [isListening, setListening] = useState(false)
|
||||
const [interim, setInterim] = useState('')
|
||||
const [hasPermission, setPermission] = useState<boolean | null>(null)
|
||||
|
||||
// 음성 인식 이벤트 (패키지 있을 때만)
|
||||
if (useSpeechRecognitionEvent) {
|
||||
useSpeechRecognitionEvent('result', (e: any) => {
|
||||
const t = e.results?.[0]?.transcript || ''
|
||||
if (e.isFinal) {
|
||||
onTranscript(t)
|
||||
setInterim('')
|
||||
setListening(false)
|
||||
} else {
|
||||
setInterim(t)
|
||||
}
|
||||
})
|
||||
useSpeechRecognitionEvent('error', () => { setListening(false); setInterim('') })
|
||||
useSpeechRecognitionEvent('end', () => { setListening(false) })
|
||||
}
|
||||
|
||||
async function toggleListen() {
|
||||
if (!ExpoSpeechRecognitionModule) {
|
||||
// 폴백: 텍스트 입력 모드 안내
|
||||
onTranscript('')
|
||||
return
|
||||
}
|
||||
|
||||
if (isListening) {
|
||||
ExpoSpeechRecognitionModule.stop()
|
||||
setListening(false)
|
||||
} else {
|
||||
try {
|
||||
const granted = await ExpoSpeechRecognitionModule.requestPermissionsAsync()
|
||||
if (!granted.granted) { setPermission(false); return }
|
||||
setPermission(true)
|
||||
await ExpoSpeechRecognitionModule.start({
|
||||
lang: 'ko-KR',
|
||||
interimResults: true,
|
||||
continuous: false,
|
||||
requiresOnDeviceRecognition: false,
|
||||
})
|
||||
setListening(true)
|
||||
} catch (e) {
|
||||
setListening(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const btnSize = size === 'small' ? 36 : 44
|
||||
const iconSize = size === 'small' ? 18 : 22
|
||||
|
||||
return (
|
||||
<View style={S.wrap}>
|
||||
<Pressable
|
||||
onPress={toggleListen}
|
||||
style={[S.btn, { width: btnSize, height: btnSize, borderRadius: btnSize / 2 },
|
||||
isListening && S.btnActive]}
|
||||
>
|
||||
{isListening
|
||||
? <ActivityIndicator color="#fff" size="small" />
|
||||
: <Text style={{ fontSize: iconSize }}>🎤</Text>
|
||||
}
|
||||
</Pressable>
|
||||
{interim ? (
|
||||
<View style={S.interimBox}>
|
||||
<Text style={S.interimText} numberOfLines={1}>{interim}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasPermission === false ? (
|
||||
<Text style={S.errText}>마이크 권한 필요</Text>
|
||||
) : null}
|
||||
{!ExpoSpeechRecognitionModule ? (
|
||||
<Text style={S.errText}>음성인식 미지원</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const S = StyleSheet.create({
|
||||
wrap: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
btn: { backgroundColor: COLORS.gnbBg, alignItems: 'center',
|
||||
justifyContent: 'center', shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.15,
|
||||
shadowRadius: 3, elevation: 2 },
|
||||
btnActive: { backgroundColor: '#dc2626' },
|
||||
interimBox: { backgroundColor: '#f0f4ff', borderRadius: 8, paddingHorizontal: 8,
|
||||
paddingVertical: 3, maxWidth: 160 },
|
||||
interimText: { fontSize: 12, color: COLORS.gnbBg },
|
||||
errText: { fontSize: 10, color: '#94a3b8' },
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user