2026-01-10 18:16:50 +09:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import androidx.compose.foundation.border
|
2026-04-15 10:08:57 +09:00
|
|
|
import androidx.compose.foundation.clickable
|
|
|
|
|
import androidx.compose.foundation.focusable
|
2026-01-10 18:16:50 +09:00
|
|
|
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
|
2026-04-15 10:08:57 +09:00
|
|
|
import androidx.compose.ui.focus.FocusRequester
|
|
|
|
|
import androidx.compose.ui.focus.focusRequester
|
2026-04-15 10:25:42 +09:00
|
|
|
import androidx.compose.ui.focus.onFocusChanged
|
2026-01-10 18:16:50 +09:00
|
|
|
import androidx.compose.ui.graphics.Color
|
|
|
|
|
import androidx.compose.ui.onExternalDrag
|
2026-01-13 16:04:25 +09:00
|
|
|
import androidx.compose.ui.text.font.FontWeight
|
2026-01-10 18:16:50 +09:00
|
|
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
|
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
|
import androidx.compose.ui.unit.sp
|
2026-03-13 11:06:20 +09:00
|
|
|
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
|
2026-03-13 16:37:53 +09:00
|
|
|
import kotlinx.coroutines.delay
|
2026-01-10 18:16:50 +09:00
|
|
|
import kotlinx.coroutines.launch
|
2026-03-13 11:06:20 +09:00
|
|
|
import kotlinx.serialization.json.Json
|
2026-01-10 18:16:50 +09:00
|
|
|
import model.AppConfig
|
2026-01-13 16:04:25 +09:00
|
|
|
import model.KisSession
|
2026-03-13 11:06:20 +09:00
|
|
|
import network.DartCodeManager
|
2026-01-10 18:16:50 +09:00
|
|
|
import network.KisAuthService
|
2026-01-13 16:04:25 +09:00
|
|
|
import network.KisTradeService
|
2026-01-10 18:16:50 +09:00
|
|
|
import org.jetbrains.exposed.sql.deleteAll
|
|
|
|
|
import org.jetbrains.exposed.sql.insert
|
|
|
|
|
import org.jetbrains.exposed.sql.transactions.transaction
|
2026-03-19 11:41:21 +09:00
|
|
|
import service.SystemSleepPreventer
|
2026-04-15 10:08:57 +09:00
|
|
|
import java.awt.Toolkit
|
|
|
|
|
import java.awt.datatransfer.DataFlavor
|
|
|
|
|
import java.io.File
|
|
|
|
|
import androidx.compose.ui.input.key.*
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-01-10 18:16:50 +09:00
|
|
|
|
|
|
|
|
|
2026-01-13 16:04:25 +09:00
|
|
|
// src/main/kotlin/ui/SettingsScreen.kt
|
2026-01-10 18:16:50 +09:00
|
|
|
@OptIn(ExperimentalComposeUiApi::class)
|
|
|
|
|
@Composable
|
2026-01-13 16:04:25 +09:00
|
|
|
fun SettingsScreen(onAuthSuccess: () -> Unit) {
|
2026-04-15 10:25:42 +09:00
|
|
|
|
|
|
|
|
var isModelBoxFocused by remember { mutableStateOf(false) }
|
|
|
|
|
var isEmbedBoxFocused by remember { mutableStateOf(false) }
|
2026-01-10 18:16:50 +09:00
|
|
|
val scope = rememberCoroutineScope()
|
2026-01-13 16:04:25 +09:00
|
|
|
var config by remember { mutableStateOf(KisSession.config) }
|
2026-03-19 17:00:53 +09:00
|
|
|
var statusMessage by remember { mutableStateOf("프로그램 초기화") }
|
2026-01-10 18:16:50 +09:00
|
|
|
|
2026-04-15 10:08:57 +09:00
|
|
|
val modelFocusRequester = remember { FocusRequester() }
|
|
|
|
|
val embedModelFocusRequester = remember { FocusRequester() }
|
|
|
|
|
|
2026-03-19 11:41:21 +09:00
|
|
|
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)
|
2026-04-03 17:02:42 +09:00
|
|
|
|
2026-03-19 11:41:21 +09:00
|
|
|
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 사이이고, 키값이 최소한 하나라도 존재할 때 자동 실행
|
2026-04-15 10:39:46 +09:00
|
|
|
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 // 성공하면 루프 탈출
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-26 13:48:26 +09:00
|
|
|
delay(30_000) // 1분마다 시간 체크
|
2026-03-19 11:41:21 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-01-13 16:04:25 +09:00
|
|
|
// 계좌번호 입력 시 데이터 자동 로드 함수
|
|
|
|
|
fun checkAndLoadConfig(accountNo: String, isReal: Boolean) {
|
|
|
|
|
val loaded = DatabaseFactory.findConfigByAccount(accountNo)
|
|
|
|
|
if (loaded != null) {
|
|
|
|
|
config = loaded
|
|
|
|
|
statusMessage = "✅ 기존 데이터를 불러왔습니다."
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-10 18:16:50 +09:00
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
LazyColumn(modifier = Modifier.fillMaxSize().padding(20.dp)) {
|
2026-01-10 18:16:50 +09:00
|
|
|
item {
|
2026-03-13 16:37:53 +09:00
|
|
|
Row(
|
|
|
|
|
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
|
|
|
|
|
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
|
|
|
|
|
){
|
|
|
|
|
Text("투자 방식", style = MaterialTheme.typography.subtitle1)
|
|
|
|
|
Spacer(Modifier.width(10.dp))
|
2026-02-19 15:47:31 +09:00
|
|
|
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) })
|
2026-03-13 16:37:53 +09:00
|
|
|
Text("실전")
|
|
|
|
|
Spacer(Modifier.width(10.dp))
|
2026-02-19 15:47:31 +09:00
|
|
|
RadioButton(selected = config.isSimulation, onClick = { config = config.copy() })
|
2026-03-13 16:37:53 +09:00
|
|
|
Text("모의")
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
2026-03-13 16:37:53 +09:00
|
|
|
|
|
|
|
|
Divider(Modifier.padding(vertical = 10.dp))
|
2026-01-14 15:42:26 +09:00
|
|
|
OutlinedTextField(
|
|
|
|
|
value = config.htsId,
|
2026-02-19 15:47:31 +09:00
|
|
|
onValueChange = { config = config.copy(htsId = it,) },
|
2026-01-14 15:42:26 +09:00
|
|
|
label = { Text("HTS ID (실시간 체결 통보용)") },
|
|
|
|
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
|
|
|
|
placeholder = { Text("한국투자증권 HTS 접속 ID를 입력하세요") }
|
|
|
|
|
)
|
2026-01-13 16:04:25 +09:00
|
|
|
// 실전 3종 입력
|
|
|
|
|
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
|
2026-03-13 16:37:53 +09:00
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-19 15:47:31 +09:00
|
|
|
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
2026-03-13 16:37:53 +09:00
|
|
|
Spacer(Modifier.height(10.dp))
|
2026-01-13 16:04:25 +09:00
|
|
|
|
|
|
|
|
// 모의 3종 입력
|
|
|
|
|
Text("모의투자 정보", fontWeight = FontWeight.Bold)
|
2026-03-13 16:37:53 +09:00
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-19 15:47:31 +09:00
|
|
|
OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
2026-01-10 18:16:50 +09:00
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
Spacer(Modifier.height(10.dp))
|
|
|
|
|
Text("정보 조회 Api Keyz", fontWeight = FontWeight.Bold)
|
2026-03-13 11:06:20 +09:00
|
|
|
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())
|
|
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
Spacer(Modifier.height(10.dp))
|
2026-01-13 16:04:25 +09:00
|
|
|
Text("AI 모델 설정", fontWeight = FontWeight.Bold)
|
2026-03-13 16:37:53 +09:00
|
|
|
Row(
|
|
|
|
|
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
|
|
|
|
|
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
|
2026-03-17 10:50:13 +09:00
|
|
|
) {
|
2026-03-13 16:37:53 +09:00
|
|
|
Box(
|
|
|
|
|
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
|
2026-04-15 10:25:42 +09:00
|
|
|
.border(
|
|
|
|
|
width = if (isModelBoxFocused) 2.dp else 1.dp,
|
|
|
|
|
color = if (isModelBoxFocused) Color.Blue else Color.Gray,
|
|
|
|
|
shape = RoundedCornerShape(8.dp)
|
|
|
|
|
)
|
|
|
|
|
// 2. Modifier 순서가 매우 중요합니다.
|
2026-04-15 10:08:57 +09:00
|
|
|
.focusRequester(modelFocusRequester)
|
2026-04-15 10:25:42 +09:00
|
|
|
.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에 포커스가 들어왔습니다!") // 로그 확인
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-15 10:08:57 +09:00
|
|
|
.focusable()
|
2026-04-15 10:25:42 +09:00
|
|
|
.clickable {
|
|
|
|
|
modelFocusRequester.requestFocus()
|
|
|
|
|
}
|
|
|
|
|
// 3. onKeyEvent 대신 onPreviewKeyEvent 사용! (핵심)
|
|
|
|
|
.onPreviewKeyEvent { event ->
|
|
|
|
|
// 디버깅용 로그: 어떤 키가 눌리는지 확인
|
|
|
|
|
println("키 입력 감지: ${event.key}, type: ${event.type}")
|
|
|
|
|
|
2026-04-15 10:08:57 +09:00
|
|
|
if (event.type == KeyEventType.KeyUp &&
|
|
|
|
|
event.key == Key.V &&
|
|
|
|
|
(event.isCtrlPressed || event.isMetaPressed)
|
|
|
|
|
) {
|
2026-04-15 10:25:42 +09:00
|
|
|
println("🚀 Ctrl+V 입력 성공!")
|
2026-04-15 10:08:57 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 10:39:46 +09:00
|
|
|
if (path.endsWith(".gguf")) config = config.copy(embedModelPath = path)
|
2026-04-15 10:25:42 +09:00
|
|
|
true // 이벤트 소비 처리
|
2026-04-15 10:08:57 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
2026-03-13 16:37:53 +09:00
|
|
|
.onExternalDrag(onDrop = { state ->
|
|
|
|
|
val data = state.dragData
|
|
|
|
|
if (data is DragData.FilesList) {
|
2026-03-17 10:50:13 +09:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-13 16:37:53 +09:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
2026-03-17 10:50:13 +09:00
|
|
|
Text(
|
2026-04-15 10:25:42 +09:00
|
|
|
if (config.modelPath.isEmpty()) "클릭하여 파란 테두리가 생기면\nCtrl+V 로 붙여넣기 하세요"
|
|
|
|
|
else config.modelPath,
|
|
|
|
|
fontSize = 12.sp,
|
|
|
|
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
2026-03-17 10:50:13 +09:00
|
|
|
)
|
2026-03-13 16:37:53 +09:00
|
|
|
}
|
2026-03-17 10:50:13 +09:00
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
Box(
|
|
|
|
|
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
|
2026-04-15 10:25:42 +09:00
|
|
|
.border(
|
|
|
|
|
width = if (isEmbedBoxFocused) 2.dp else 1.dp,
|
|
|
|
|
color = if (isEmbedBoxFocused) Color.Blue else Color.Gray,
|
|
|
|
|
shape = RoundedCornerShape(8.dp)
|
|
|
|
|
)
|
|
|
|
|
// 2. Modifier 순서가 매우 중요합니다.
|
2026-04-15 10:08:57 +09:00
|
|
|
.focusRequester(embedModelFocusRequester)
|
2026-04-15 10:25:42 +09:00
|
|
|
.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에 포커스가 들어왔습니다!") // 로그 확인
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-15 10:08:57 +09:00
|
|
|
.focusable()
|
2026-04-15 10:25:42 +09:00
|
|
|
.clickable {
|
|
|
|
|
embedModelFocusRequester.requestFocus()
|
|
|
|
|
}
|
2026-04-15 10:08:57 +09:00
|
|
|
// 4. 키보드 이벤트 (Ctrl+V / Cmd+V) 감지
|
|
|
|
|
.onKeyEvent { event ->
|
2026-04-15 10:25:42 +09:00
|
|
|
println("event >> $event")
|
2026-04-15 10:08:57 +09:00
|
|
|
// 키를 떼는 순간(KeyUp) && 누른 키가 'V' && (Ctrl 혹은 Cmd 키가 눌린 상태)
|
|
|
|
|
if (event.type == KeyEventType.KeyUp &&
|
|
|
|
|
event.key == Key.V &&
|
|
|
|
|
(event.isCtrlPressed || event.isMetaPressed)
|
|
|
|
|
) {
|
|
|
|
|
val rawUri = getPastedPathFromClipboard()
|
2026-04-15 10:25:42 +09:00
|
|
|
println("rawUri >> $rawUri")
|
2026-04-15 10:08:57 +09:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-13 16:37:53 +09:00
|
|
|
.onExternalDrag(onDrop = { state ->
|
|
|
|
|
val data = state.dragData
|
|
|
|
|
if (data is DragData.FilesList) {
|
2026-03-17 10:50:13 +09:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-13 16:37:53 +09:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
2026-03-17 10:50:13 +09:00
|
|
|
Text(
|
|
|
|
|
if (config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath,
|
|
|
|
|
fontSize = 12.sp
|
|
|
|
|
)
|
2026-03-13 16:37:53 +09:00
|
|
|
}
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
2026-03-13 16:37:53 +09:00
|
|
|
Spacer(Modifier.height(10.dp))
|
2026-01-10 18:16:50 +09:00
|
|
|
|
|
|
|
|
Button(
|
|
|
|
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
|
|
|
|
onClick = {
|
|
|
|
|
scope.launch {
|
2026-03-19 11:41:21 +09:00
|
|
|
authenticateAndStart()
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-13 16:04:25 +09:00
|
|
|
) { Text("설정 저장 및 실행") }
|
|
|
|
|
Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp))
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-13 16:04:25 +09:00
|
|
|
}
|