package ui import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.DragData import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.onExternalDrag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.DEFAULT import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import model.AppConfig import model.KisSession import network.DartCodeManager import network.KisAuthService import network.KisTradeService import org.jetbrains.exposed.sql.deleteAll import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.transactions.transaction import service.SystemSleepPreventer import java.awt.Toolkit import java.awt.datatransfer.DataFlavor import java.io.File import androidx.compose.ui.input.key.* import network.NewsService fun getPastedPathFromClipboard(): String? { val clipboard = Toolkit.getDefaultToolkit().systemClipboard try { // 1. 파일 자체가 복사된 경우 (탐색기/파인더에서 Ctrl+C / Cmd+C) if (clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor)) { val files = clipboard.getData(DataFlavor.javaFileListFlavor) as? List<*> val file = files?.firstOrNull() as? File if (file != null) return file.absolutePath } // 2. 텍스트(경로 문자열)가 복사된 경우 if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) { val text = clipboard.getData(DataFlavor.stringFlavor) as? String if (text != null) { // 앞뒤 공백, 따옴표, file:// 접두사 등을 깔끔하게 제거 var cleanPath = text.trim('"', '\'', ' ', '\n', '\r') .removePrefix("file://").removePrefix("file:") // 윈도우 드라이브 문자 앞 슬래시 제거 (예: /C:/ -> C:/) if (cleanPath.startsWith("/") && cleanPath.getOrNull(2) == ':') { cleanPath = cleanPath.drop(1) } return cleanPath } } } catch (e: Exception) { e.printStackTrace() } return null } // src/main/kotlin/ui/SettingsScreen.kt @OptIn(ExperimentalComposeUiApi::class) @Composable fun SettingsScreen(onAuthSuccess: () -> Unit) { var isModelBoxFocused by remember { mutableStateOf(false) } var isEmbedBoxFocused by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() var config by remember { mutableStateOf(KisSession.config) } var statusMessage by remember { mutableStateOf("프로그램 초기화") } val modelFocusRequester = remember { FocusRequester() } val embedModelFocusRequester = remember { FocusRequester() } val authenticateAndStart: suspend () -> Unit = { var retryCount = 0 val maxRetries = 3 val totalDelaySeconds = 90 var isAuthCompleted = false while (retryCount <= maxRetries && !isAuthCompleted) { if (retryCount > 0) { for (secondsLeft in totalDelaySeconds downTo 1) { statusMessage = "⚠️ 인증 실패. ${secondsLeft}초 후 자동으로 다시 시도합니다. (시도 ${retryCount}/${maxRetries})" delay(1000L) } } statusMessage = if (retryCount == 0) "⏳ 인증 시도 중..." else "⏳ ${retryCount}차 재시도 중..." KisSession.config = config DatabaseFactory.saveConfig(config) DartCodeManager.updateCorpCodes(HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } }) val authSuccess = KisAuthService.refreshAllTokens() val wsKeySuccess = KisTradeService.refreshWebsocketKey() if (authSuccess && wsKeySuccess) { statusMessage = "✅ 인증 성공! LLM 시작 중..." isAuthCompleted = true onAuthSuccess() // 여기서 대시보드로 넘어감 } else { retryCount++ if (retryCount > maxRetries) { statusMessage = "❌ 인증 실패. 3회 재시도 후 중단되었습니다." } } } } LaunchedEffect(config) { while (true) { val now = java.time.LocalTime.now(java.time.ZoneId.of("Asia/Seoul")) // 08:30 ~ 15:30 사이이고, 키값이 최소한 하나라도 존재할 때 자동 실행 if (now.isAfter(java.time.LocalTime.of(7, 40)) && now.isBefore(java.time.LocalTime.of(18, 0))) { if (config.realAppKey.isNotEmpty() && config.vtsAppKey.isNotEmpty() && config.embedModelPath.isNotEmpty() && config.modelPath.isNotEmpty()) { SystemSleepPreventer.wakeDisplay() // 모니터 켜기 statusMessage = "⏰ 자동 실행 시간(08:30)입니다. 시스템을 가동합니다." authenticateAndStart() break // 성공하면 루프 탈출 } } delay(30_000) // 1분마다 시간 체크 } } // 계좌번호 입력 시 데이터 자동 로드 함수 fun checkAndLoadConfig(accountNo: String, isReal: Boolean) { val loaded = DatabaseFactory.findConfigByAccount(accountNo) if (loaded != null) { config = loaded statusMessage = "✅ 기존 데이터를 불러왔습니다." } } LazyColumn(modifier = Modifier.fillMaxSize().padding(20.dp)) { item { Divider(Modifier.padding(vertical = 10.dp)) OutlinedTextField( value = config.htsId, onValueChange = { config = config.copy(htsId = it,) }, label = { Text("HTS ID (실시간 체결 통보용)") }, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), placeholder = { Text("한국투자증권 HTS 접속 ID를 입력하세요") } ) // 실전 3종 입력 Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold) Row( modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 ) { OutlinedTextField( value = config.realAccountNo, onValueChange = { config = config.copy(realAccountNo = it,) if (it.length >= 8) checkAndLoadConfig(it, true) }, label = { Text("실전 계좌번호") }, modifier = Modifier.weight(0.5f)) OutlinedTextField( value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it,) }, label = { Text("실전 App Key") }, modifier = Modifier.weight(0.5f) ) } OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) Spacer(Modifier.height(10.dp)) // // 모의 3종 입력 // Text("모의투자 정보", fontWeight = FontWeight.Bold) // Row( // modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 // verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 // ) { // OutlinedTextField(value = config.vtsAccountNo, onValueChange = { // config = config.copy(vtsAccountNo = it,) // if (it.length >= 8) checkAndLoadConfig(it, false) // }, label = { Text("모의 계좌번호") }, modifier = Modifier.weight(0.5f)) // OutlinedTextField( // value = config.vtsAppKey, // onValueChange = { config = config.copy(vtsAppKey = it,) }, // label = { Text("모의 App Key") }, // modifier = Modifier.weight(0.5f) // ) // } // OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) // Spacer(Modifier.height(10.dp)) Text("정보 조회 Api Keyz", fontWeight = FontWeight.Bold) OutlinedTextField(value = config.nAppKey, onValueChange = { config = config.copy(nAppKey = it,) }, label = { Text("NAVER Client ID") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = config.nSecretKey, onValueChange = { config = config.copy(nSecretKey = it,) }, label = { Text("NAVER Client Secret") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) OutlinedTextField(value = config.dAppKey, onValueChange = { config = config.copy(dAppKey = it,) }, label = { Text("Dart ApiKey") }, modifier = Modifier.fillMaxWidth()) Spacer(Modifier.height(10.dp)) Text("AI 모델 설정", fontWeight = FontWeight.Bold) Row( modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 ) { Box( modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) .border( width = if (isModelBoxFocused) 2.dp else 1.dp, color = if (isModelBoxFocused) Color.Blue else Color.Gray, shape = RoundedCornerShape(8.dp) ) // 2. Modifier 순서가 매우 중요합니다. .focusRequester(modelFocusRequester) .onFocusChanged { focusState -> isModelBoxFocused = focusState.isFocused // 포커스 여부 저장 if (focusState.isFocused) { val rawUri = getPastedPathFromClipboard() println("rawUri $rawUri") if (rawUri != null && rawUri.endsWith(".gguf", ignoreCase = true)) { var path = rawUri.removePrefix("file://").removePrefix("file:") // 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거 if (path.startsWith("/") && path.getOrNull(2) == ':') { path = path.drop(1) } if (path.endsWith(".gguf")) config = config.copy(modelPath = path) true // 이벤트 소비 처리 } println("✅ Box에 포커스가 들어왔습니다!") // 로그 확인 } } .focusable() .clickable { modelFocusRequester.requestFocus() } // 3. onKeyEvent 대신 onPreviewKeyEvent 사용! (핵심) .onPreviewKeyEvent { event -> // 디버깅용 로그: 어떤 키가 눌리는지 확인 println("키 입력 감지: ${event.key}, type: ${event.type}") if (event.type == KeyEventType.KeyUp && event.key == Key.V && (event.isCtrlPressed || event.isMetaPressed) ) { println("🚀 Ctrl+V 입력 성공!") val rawUri = getPastedPathFromClipboard() if (rawUri != null && rawUri.endsWith(".gguf", ignoreCase = true)) { var path = rawUri.removePrefix("file://").removePrefix("file:") // 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거 if (path.startsWith("/") && path.getOrNull(2) == ':') { path = path.drop(1) } if (path.endsWith(".gguf")) config = config.copy(embedModelPath = path) true // 이벤트 소비 처리 } } false } .onExternalDrag(onDrop = { state -> val data = state.dragData if (data is DragData.FilesList) { val rawUri = data.readFiles().firstOrNull() if (rawUri != null) { // 1. file:// 또는 file: 접두사 제거 var path = rawUri.removePrefix("file://").removePrefix("file:") // 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거 if (path.startsWith("/") && path.getOrNull(2) == ':') { path = path.drop(1) } if (path.endsWith(".gguf")) config = config.copy(modelPath = path) } } }), contentAlignment = Alignment.Center ) { Text( if (config.modelPath.isEmpty()) "클릭하여 파란 테두리가 생기면\nCtrl+V 로 붙여넣기 하세요" else config.modelPath, fontSize = 12.sp, textAlign = androidx.compose.ui.text.style.TextAlign.Center ) } Box( modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) .border( width = if (isEmbedBoxFocused) 2.dp else 1.dp, color = if (isEmbedBoxFocused) Color.Blue else Color.Gray, shape = RoundedCornerShape(8.dp) ) // 2. Modifier 순서가 매우 중요합니다. .focusRequester(embedModelFocusRequester) .onFocusChanged { focusState -> isEmbedBoxFocused = focusState.isFocused // 포커스 여부 저장 if (focusState.isFocused) { val rawUri = getPastedPathFromClipboard() println("rawUri $rawUri") if (rawUri != null && rawUri.endsWith(".gguf", ignoreCase = true)) { var path = rawUri.removePrefix("file://").removePrefix("file:") // 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거 if (path.startsWith("/") && path.getOrNull(2) == ':') { path = path.drop(1) } if (path.endsWith(".gguf")) config = config.copy(modelPath = path) true // 이벤트 소비 처리 } println("✅ Box에 포커스가 들어왔습니다!") // 로그 확인 } } .focusable() .clickable { embedModelFocusRequester.requestFocus() } // 4. 키보드 이벤트 (Ctrl+V / Cmd+V) 감지 .onKeyEvent { event -> println("event >> $event") // 키를 떼는 순간(KeyUp) && 누른 키가 'V' && (Ctrl 혹은 Cmd 키가 눌린 상태) if (event.type == KeyEventType.KeyUp && event.key == Key.V && (event.isCtrlPressed || event.isMetaPressed) ) { val rawUri = getPastedPathFromClipboard() println("rawUri >> $rawUri") if (rawUri != null && rawUri.endsWith(".gguf", ignoreCase = true)) { var path = rawUri.removePrefix("file://").removePrefix("file:") // 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거 if (path.startsWith("/") && path.getOrNull(2) == ':') { path = path.drop(1) } if (path.endsWith(".gguf")) config = config.copy(modelPath = path) return@onKeyEvent true // 이벤트 소비 처리 } } false } .onExternalDrag(onDrop = { state -> val data = state.dragData if (data is DragData.FilesList) { val rawUri = data.readFiles().firstOrNull() if (rawUri != null) { // 1. file:// 또는 file: 접두사 제거 var embedModelPath = rawUri.removePrefix("file://").removePrefix("file:") // 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거 if (embedModelPath.startsWith("/") && embedModelPath.getOrNull(2) == ':') { embedModelPath = embedModelPath.drop(1) } if (embedModelPath.endsWith(".gguf")) config = config.copy(embedModelPath = embedModelPath) } } }), contentAlignment = Alignment.Center ) { Text( if (config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp ) } } Spacer(Modifier.height(10.dp)) Button( modifier = Modifier.fillMaxWidth().height(50.dp), onClick = { scope.launch { authenticateAndStart() } } ) { Text("설정 저장 및 실행") } Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp)) } } }