This commit is contained in:
lun_admin 2026-03-17 10:50:13 +09:00
parent 44e14dd207
commit 1eb89bcc3f
49 changed files with 207 additions and 81 deletions

View File

@ -80,7 +80,7 @@ fun main() = application {
// 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치) // 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
val binPath = getLlamaBinPath() val binPath = getLlamaBinPath()
val windowState = rememberWindowState( val windowState = rememberWindowState(
placement = WindowPlacement.Fullscreen placement = WindowPlacement.Floating
) )
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) { Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) {
var currentScreen by remember { mutableStateOf(AppScreen.Settings) } var currentScreen by remember { mutableStateOf(AppScreen.Settings) }

View File

@ -1,6 +1,7 @@
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import model.AppConfig import model.AppConfig
import network.TradingDecision
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.javatime.datetime import org.jetbrains.exposed.sql.javatime.datetime
@ -413,7 +414,7 @@ object TradingLogStore {
} }
} }
fun addLog(tradingDecision: TradingDecision , decision: String, log: String) { fun addLog(tradingDecision: TradingDecision, decision: String, log: String) {
synchronized(this) { synchronized(this) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0) if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
decisionLogs.add( decisionLogs.add(

View File

@ -1,23 +1,36 @@
// src/main/kotlin/network/RagService.kt package network// src/main/kotlin/network/RagService.kt
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
import dev.langchain4j.data.document.Metadata import dev.langchain4j.data.document.Metadata
import dev.langchain4j.data.message.UserMessage
import dev.langchain4j.data.segment.TextSegment import dev.langchain4j.data.segment.TextSegment
import dev.langchain4j.exception.InternalServerException
import dev.langchain4j.model.openai.OpenAiChatModel import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel import dev.langchain4j.model.openai.OpenAiEmbeddingModel
import dev.langchain4j.service.AiServices
import dev.langchain4j.service.SystemMessage
import dev.langchain4j.store.embedding.EmbeddingSearchRequest import dev.langchain4j.store.embedding.EmbeddingSearchRequest
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import model.CandleData import kotlinx.serialization.json.add
import network.DartCodeManager import kotlinx.serialization.json.addJsonObject
import network.FinancialMapper import kotlinx.serialization.json.buildJsonObject
import network.FinancialStatement import kotlinx.serialization.json.jsonArray
import network.NewsService import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.lucene.store.MMapDirectory import org.apache.lucene.store.MMapDirectory
import org.slf4j.MDC.put
import service.FinancialAnalyzer import service.FinancialAnalyzer
import service.InvestmentScores import service.InvestmentScores
import service.TechnicalAnalyzer import service.TechnicalAnalyzer
@ -25,9 +38,10 @@ import service.TradingDecisionCallback
import service.UrlCacheManager import service.UrlCacheManager
import java.nio.file.Paths import java.nio.file.Paths
import java.time.Duration import java.time.Duration
import java.util.concurrent.TimeUnit
interface TradingAnalyst { interface TradingAnalyst {
@dev.langchain4j.service.SystemMessage(""" @SystemMessage("""
You are a Senior Stock Analyst. You are a Senior Stock Analyst.
Analyze the data and provide a decision in JSON format. Analyze the data and provide a decision in JSON format.
You must respond ONLY with a valid JSON object. You must respond ONLY with a valid JSON object.
@ -48,13 +62,13 @@ object RagService {
.apiKey("unused") .apiKey("unused")
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도 .temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
.timeout(Duration.ofSeconds(60)) .timeout(Duration.ofSeconds(60))
.frequencyPenalty(1.1) // .frequencyPenalty(1.1)
.maxTokens(500) // 👈 루프 방지를 위해 반드시 짧게 제한! .maxTokens(400) // 👈 루프 방지를 위해 반드시 짧게 제한!
// 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요 // 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요
.responseFormat("json_object") .responseFormat("json_object")
.build() .build()
private val analyst = dev.langchain4j.service.AiServices.builder(TradingAnalyst::class.java) private val analyst = AiServices.builder(TradingAnalyst::class.java)
.chatModel(chatModel) .chatModel(chatModel)
.build() .build()
@ -139,8 +153,18 @@ object RagService {
object JsonSanitizer { object JsonSanitizer {
fun formatJson(raw: String): String { fun formatJson(raw: String): String {
// 실제 응답 로그 출력 (디버깅용)
println("📥 [AI Raw Response]:\n$raw")
val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL) val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL)
return raw.trim() val match = regex.find(raw)?.value
if (match == null) {
println("⚠️ [JsonSanitizer] JSON 형식을 찾을 수 없습니다.")
return "{}" // 빈 객체라도 반환하여 EOF 방지
}
return match.trim()
.removePrefix("```json") .removePrefix("```json")
.removePrefix("```") .removePrefix("```")
.removeSuffix("```") .removeSuffix("```")
@ -264,13 +288,80 @@ object RagService {
//// println(response) //// println(response)
// return response.aiMessage().text() // return response.aiMessage().text()
// } // }
private const val LLM_API_URL = "http://127.0.0.1:8080/v1/chat/completions"
private suspend fun callLlamaWithSchema(prompt: String): String {
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
// 문자열 치환 대신 안전한 JSON 객체 빌더 사용
val requestBodyJson = buildJsonObject {
put("model", "local-model")
put("temperature", 0.1) // 0.1 유지 (결정론적 응답)
put("top_p", 0.9)
put("max_tokens", 500)
putJsonArray("messages") {
addJsonObject {
put("role", "system")
put("content", "You are a helpful AI financial analyst. You must output responses ONLY in valid JSON format.")
}
addJsonObject {
put("role", "user")
put("content", prompt)
}
}
// 💡 복잡한 json_schema를 지우고, 단순히 JSON 형식으로만 내보내라고 지시합니다.
putJsonObject("response_format") {
put("type", "json_object")
}
}.toString()
println("requestBodyJson =>> $requestBodyJson")
val request = Request.Builder()
.url(LLM_API_URL)
.post(requestBodyJson.toRequestBody(jsonMediaType))
.build()
return kotlinx.coroutines.Dispatchers.IO.let {
kotlinx.coroutines.withContext(it) {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("LLM API Error: ${response.code} ${response.message}")
val responseBody = response.body?.string() ?: "{}"
val json = Json.parseToJsonElement(responseBody).jsonObject
json["choices"]?.jsonArray?.get(0)?.jsonObject?.get("message")?.jsonObject?.get("content")?.jsonPrimitive?.content ?: "{}"
}
}
}
}
private val httpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
suspend fun decideTrading( suspend fun decideTrading(
stockName: String, stockName: String,
scores: InvestmentScores, // 직접 계산한 점수 객체 scores: InvestmentScores, // 직접 계산한 점수 객체
financialStmt: FinancialStatement, // 매핑된 재무 수치 객체 financialStmt: FinancialStatement, // 매핑된 재무 수치 객체
tempDecision: TradingDecision tempDecision: TradingDecision
): TradingDecision? { ): TradingDecision? {
// 💡 1. 뉴스 데이터가 유효한지(100자 이상인지) 확인
val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }
// 💡 2. 동적 데이터 섹션 구성
val newsDataSection = if (validNews != null) {
"3. News Context: $validNews"
} else {
"3. News Context: None available. Base your decision ONLY on System Scores and Financials."
}
// 💡 3. 동적 제약 조건 구성
val newsConstraint = if (validNews != null) {
"- Match Financials with News: If profit is negative but news is hyped, stay CAUTIOUS (HOLD)."
} else {
"- No news data is available. Rely strictly on Financials and System Scores for your 'decision' and 'reason'."
}
val prompt = """ val prompt = """
# Task: Senior AI Investment Analyst # Task: Senior AI Investment Analyst
Analyze the stock '$stockName' and determine the final trading decision based on the data below. Analyze the stock '$stockName' and determine the final trading decision based on the data below.
@ -278,51 +369,52 @@ Analyze the stock '$stockName' and determine the final trading decision based on
# Data # Data
1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm}) 1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm})
2. Financials: Operating Profit ${if(financialStmt.isOperatingProfitPositive) "PROFIT" else "LOSS"} (Growth: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%), ROE: ${"%.2f".format(financialStmt.roe)}%, Debt: ${"%.2f".format(financialStmt.debtRatio)}% 2. Financials: Operating Profit ${if(financialStmt.isOperatingProfitPositive) "PROFIT" else "LOSS"} (Growth: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%), ROE: ${"%.2f".format(financialStmt.roe)}%, Debt: ${"%.2f".format(financialStmt.debtRatio)}%
3. News Context: ${tempDecision.newsContext?.take(400)} // 👈 뉴스 길이를 물리적으로 제한 $newsDataSection
# Constraints # Constraints
1. 모든 점수와 confidence는 0에서 100 사이의 **정수(Integer)**로만 작성하십시오. - Copy the exact 'System Scores' from the Data section into the output JSON.
- Match Financials with News: If profit is negative but news is hyped, stay CAUTIOUS (HOLD). - Match Financials with News: If profit is negative but news is hyped, stay CAUTIOUS (HOLD).
- Synchronization: High scalping score + positive news momentum = Higher BUY confidence. - The "reason" field MUST be written in KOREAN and MUST NOT exceed 50 characters. Keep it concise.
- Output: Response ONLY in valid JSON format. No extra text. - Output ONLY a valid JSON object matching the exact structure below. DO NOT output placeholder text like '(integer)'.
# Output JSON Format (Reason must be in Korean) # Example Output JSON Format
{ {
"ultraShortScore": ${scores.ultraShort}, "ultraShortScore": 0,
"shortTermScore": ${scores.shortTerm}, "shortTermScore": 0,
"midTermScore": ${scores.midTerm}, "midTermScore": 0,
"longTermScore": ${scores.longTerm}, "longTermScore": 0,
"decision": "BUY/SELL/HOLD", "decision": "HOLD",
"reason": "재무와 뉴스를 대조한 분석 결과 (한국어)", "reason": "적자 지속 및 네수파립 임상 대기 중으로 관망 필요",
"confidence": 0~100 "confidence": 50
} }
""".trimIndent() """.trimIndent()
val response = chatModel.chat(UserMessage.from(prompt))
val rawResponse = response.aiMessage().text()
val jsonResponse = JsonSanitizer.formatJson(rawResponse)
// println("📥 [AI Raw JSON]:\n$jsonResponse")
// 2. 유연한 파서 설정 (소수점 및 예외 상황 대응)
val lenientJson = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
// JSON 파싱 (Kotlinx Serialization 활용)
return try { return try {
// println(jsonResponse) val rawResponse = callLlamaWithSchema(prompt)
val decision = lenientJson.decodeFromString<TradingDecision>(jsonResponse) println("📥 [AI Strict JSON]:\n$rawResponse")
decision.financialData = tempDecision.financialData
decision.newsContext = tempDecision.newsContext // 엄격한 스키마가 적용되었으므로 JsonSanitizer 없이 바로 파싱 가능
decision.techSummary = tempDecision.techSummary val lenientJson = Json {
decision.stockCode = tempDecision.stockCode ignoreUnknownKeys = true
decision.corpName = tempDecision.corpName isLenient = true
decision.stockName = tempDecision.stockName coerceInputValues = true
}
val decision = lenientJson.decodeFromString<TradingDecision>(rawResponse)
// 데이터 매핑
decision.apply {
financialData = tempDecision.financialData
newsContext = tempDecision.newsContext
techSummary = tempDecision.techSummary
stockCode = tempDecision.stockCode
corpName = tempDecision.corpName
this.stockName = tempDecision.stockName
}
decision decision
} catch (e: dev.langchain4j.exception.InternalServerException) { } catch (e: InternalServerException) {
// 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리 // 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리
println("🚨 [AI Server Error] ${e.message}") println("🚨 [AI Server Error] ${e.message}")
if (e.message?.contains("Context size") == true) { if (e.message?.contains("Context size") == true) {

View File

@ -1,9 +1,8 @@
package service package service
import AutoTradeItem import AutoTradeItem
import TradingDecision import network.TradingDecision
import TradingLogStore import TradingLogStore
import androidx.compose.runtime.remember
import getLlamaBinPath import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -27,11 +26,11 @@ import model.RankingStock
import model.RankingType import model.RankingType
import model.UnifiedBalance import model.UnifiedBalance
import network.DartCodeManager import network.DartCodeManager
import network.FinancialMapper
import network.FinancialStatement import network.FinancialStatement
import network.KisAuthService import network.KisAuthService
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import network.RagService
import network.StockUniverseLoader import network.StockUniverseLoader
import util.MarketUtil import util.MarketUtil
import java.time.LocalDateTime import java.time.LocalDateTime

View File

@ -2,13 +2,9 @@ package service
import com.microsoft.playwright.Browser import com.microsoft.playwright.Browser
import com.microsoft.playwright.Playwright import com.microsoft.playwright.Playwright
import com.microsoft.playwright.BrowserType
import com.microsoft.playwright.Page import com.microsoft.playwright.Page
import com.microsoft.playwright.options.LoadState import com.microsoft.playwright.options.LoadState
import com.microsoft.playwright.options.WaitUntilState import com.microsoft.playwright.options.WaitUntilState
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -18,6 +14,7 @@ import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import model.NewsItem import model.NewsItem
import network.CorpInfo import network.CorpInfo
import network.RagService
import java.net.URL import java.net.URL
import kotlin.random.Random import kotlin.random.Random

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.RagService
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.InputStreamReader import java.io.InputStreamReader
@ -31,7 +32,7 @@ object LlamaServerManager {
else -> 0 to 4 // 인텔 맥 2017 등 else -> 0 to 4 // 인텔 맥 2017 등
} }
val command = listOf( val command = mutableListOf(
binPath, binPath,
"-m", modelPath, "-m", modelPath,
"--port", port.toString(), "--port", port.toString(),
@ -40,7 +41,13 @@ object LlamaServerManager {
"-t", threads.toString(), "-t", threads.toString(),
"--embedding" "--embedding"
) )
if (port != 8081) { // 텍스트 생성용 모델에만 적용
command.addAll(listOf(
"-b", "512", // Batch size (토큰 병렬 처리량 제한으로 연산 안정화)
"--threads-batch", threads.toString(),
"-fa","on" // Flash Attention 활성화 (메모리 절약 및 긴 컨텍스트 연산 안정성 증가)
))
}
scope.launch { scope.launch {
try { try {
val pb = ProcessBuilder(command) val pb = ProcessBuilder(command)

View File

@ -59,18 +59,25 @@ object SystemSleepPreventer {
* 맥의 절전 모드 디스플레이 취침을 방지하는 명령 실행 * 맥의 절전 모드 디스플레이 취침을 방지하는 명령 실행
*/ */
fun start() { fun start() {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
val isWin = os.contains("win")
val root = LoggerFactory.getLogger("Exposed") as Logger val root = LoggerFactory.getLogger("Exposed") as Logger
root.level = Level.ERROR root.level = Level.ERROR
checkAndRequestAccessibility() if (!isWin) {
if (process?.isAlive == true) return checkAndRequestAccessibility()
}
try { if (process?.isAlive == true) return
// -i: 시스템 절전 방지, -d: 디스플레이 취침 방지, -m: 디스크 유휴 상태 방지 if (!isWin) {
val command = listOf("caffeinate", "-i", "-d", "-m") try {
process = ProcessBuilder(command).start() // -i: 시스템 절전 방지, -d: 디스플레이 취침 방지, -m: 디스크 유휴 상태 방지
println("☕ [System] caffeinate 실행됨: 앱이 켜져 있는 동안 절전 모드가 방지됩니다.") val command = listOf("caffeinate", "-i", "-d", "-m")
} catch (e: Exception) { process = ProcessBuilder(command).start()
println("⚠️ [System] caffeinate 실행 실패: ${e.message}") println("☕ [System] caffeinate 실행됨: 앱이 켜져 있는 동안 절전 모드가 방지됩니다.")
} catch (e: Exception) {
println("⚠️ [System] caffeinate 실행 실패: ${e.message}")
}
} }
} }

View File

@ -2,7 +2,7 @@
package ui package ui
import AutoTradeItem import AutoTradeItem
import TradingDecision import network.TradingDecision
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
@ -19,10 +19,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.CandleData import model.CandleData
import model.ConfigIndex import model.ConfigIndex

View File

@ -2,7 +2,7 @@
package ui package ui
import AutoTradeItem import AutoTradeItem
import TradingDecision import network.TradingDecision
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -18,7 +18,6 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.ConfigIndex import model.ConfigIndex

View File

@ -128,32 +128,60 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
Row( Row(
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
){ ) {
Box( Box(
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state -> .onExternalDrag(onDrop = { state ->
val data = state.dragData val data = state.dragData
if (data is DragData.FilesList) { if (data is DragData.FilesList) {
val path = data.readFiles().firstOrNull()?.removePrefix("file:") val rawUri = data.readFiles().firstOrNull()
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path,) 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 contentAlignment = Alignment.Center
) { ) {
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp) Text(
if (config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath,
fontSize = 12.sp
)
} }
Box( Box(
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state -> .onExternalDrag(onDrop = { state ->
val data = state.dragData val data = state.dragData
if (data is DragData.FilesList) { if (data is DragData.FilesList) {
val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:") val rawUri = data.readFiles().firstOrNull()
if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,) 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 contentAlignment = Alignment.Center
) { ) {
Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp) Text(
if (config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath,
fontSize = 12.sp
)
} }
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))

View File

@ -2,7 +2,7 @@ package ui
import TradingDecision import network.TradingDecision
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*