From b60a19ada2cb76dee37760daf9577d85002d811a Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Wed, 3 Jun 2026 09:17:03 +0900 Subject: [PATCH] sync: update from workspace (latest ITSM/CICD/DR changes) --- app/(tabs)/_layout.tsx | 7 ++ app/(tabs)/voice.tsx | 183 +++++++++++++++++++++++++++++++ components/SuggestedCommands.tsx | 88 +++++++++++++++ components/VoiceInput.tsx | 111 +++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 app/(tabs)/voice.tsx create mode 100644 components/SuggestedCommands.tsx create mode 100644 components/VoiceInput.tsx 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' }, +})