diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index fb7c355f..934d5ddc 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -91,6 +91,13 @@ export default function TabLayout() {
tabBarIcon: ({ focused }) => ,
}}
/>
+ ,
+ }}
+ />
(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 (
+
+ {/* ํค๋ */}
+
+ ๐ค ์์ฑ ๋ช
๋ น
+ ํ๊ตญ์ด๋ก ๋งํ๊ฑฐ๋ ์๋ ๋น ๋ฅธ ๋ช
๋ น์ ํญํ์ธ์
+
+
+ {/* ๋ฉ์ธ ๋ง์ดํฌ ๋ฒํผ */}
+
+
+
+
+
+ {isListening ? '๋ฃ๊ณ ์์ด์... ๋ช
๋ น์ ๋ง์ํ์ธ์' : '๋ง์ดํฌ๋ฅผ ํญํ์ฌ ์์'}
+
+
+
+ {/* ์ธ์ ๊ฒฐ๊ณผ */}
+ {loading && (
+
+
+ ์ฒ๋ฆฌ ์ค...
+
+ )}
+ {!loading && result && (
+
+ {transcript ? ๐ฌ "{transcript}" : null}
+ {result.cmd ? (
+
+ {result.cmd}
+
+ ) : null}
+ {result.response}
+
+ )}
+
+ {/* ๋น ๋ฅธ ๋ช
๋ น ๋ฒํผ */}
+ โก ๋น ๋ฅธ ๋ช
๋ น
+
+ {VOICE_SHORTCUTS.map((s, i) => (
+ runShortcut(s.command, s.label)} style={S.shortBtn}>
+ {s.icon}
+ {s.label}
+
+ ))}
+
+
+ {/* ์ฌ์ฉ ์ด๋ ฅ */}
+ {history.length > 0 && (
+ <>
+ ๐ ์ต๊ทผ ๋ช
๋ น
+
+ {history.map((h, i) => (
+
+ {h.time}
+ {h.text}
+ {h.cmd}
+
+ ))}
+
+ >
+ )}
+
+ {/* ๋์๋ง */}
+
+ ๐ก ์์ฑ ๋ช
๋ น ์์
+ {['"์๋ฒ ์ํ ํ์ธํด์ค"', '"SR ๋ง๋ค์ด์ค"', '"๋ฐฐํฌ ์์ํด"', '"์ฅ์ ๋ณด๊ณ ํด์ค"'].map((e, i) => (
+ โข {e}
+ ))}
+
+
+ )
+}
+
+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 },
+})
diff --git a/components/SuggestedCommands.tsx b/components/SuggestedCommands.tsx
new file mode 100644
index 00000000..14508ea6
--- /dev/null
+++ b/components/SuggestedCommands.tsx
@@ -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(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 (
+
+ ์ถ์ฒ ๋ช
๋ น
+
+ {suggestions.map((cmd, i) => (
+ handleSelect(cmd)} style={S.chip}>
+ {cmd}
+
+ ))}
+
+
+ )
+}
+
+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' },
+})
diff --git a/components/VoiceInput.tsx b/components/VoiceInput.tsx
new file mode 100644
index 00000000..4fee2184
--- /dev/null
+++ b/components/VoiceInput.tsx
@@ -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(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 (
+
+
+ {isListening
+ ?
+ : ๐ค
+ }
+
+ {interim ? (
+
+ {interim}
+
+ ) : null}
+ {hasPermission === false ? (
+ ๋ง์ดํฌ ๊ถํ ํ์
+ ) : null}
+ {!ExpoSpeechRecognitionModule ? (
+ ์์ฑ์ธ์ ๋ฏธ์ง์
+ ) : null}
+
+ )
+}
+
+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' },
+})