This commit is contained in:
lunaticbum 2025-10-13 13:26:39 +09:00
parent a58bbc8fee
commit 6d66a0147c
11 changed files with 1012 additions and 398 deletions

View File

@ -20,6 +20,7 @@ import androidx.compose.ui.window.application
import coil3.ImageLoader
import coil3.compose.rememberAsyncImagePainter
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import core.BrowserManager.quitChromeDriver
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
@ -36,420 +37,27 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonClassDiscriminator
import org.jsoup.Jsoup
import org.openqa.selenium.By
import org.openqa.selenium.WebDriverException
import org.openqa.selenium.chrome.ChromeDriver
import org.openqa.selenium.chrome.ChromeOptions
import ui.App
import utils.Global.httpClient
import java.awt.FileDialog
import java.awt.Frame
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.prefs.Preferences
import kotlin.coroutines.cancellation.CancellationException
// --- 데이터 클래스 정의 (Sealed Interface 사용) ---
@Serializable
@JsonClassDiscriminator("type")
sealed interface SealedLLMStreamResponse {
@Serializable
@SerialName("textResponseChunk")
data class TextResponseChunk(val uuid: String, val textResponse: String?, val sources: List<Source> = emptyList(), val close: Boolean, val error: Boolean) : SealedLLMStreamResponse
@Serializable
@SerialName("finalizeResponseStream")
data class FinalizeResponseStream(val uuid: String, val chatId: Int, val metrics: Metrics?, val close: Boolean, val error: Boolean) : SealedLLMStreamResponse
@Serializable
@SerialName("abort")
data class AbortResponse(val uuid: String? = null, val textResponse: String? = null, val close: Boolean = true, val error: Boolean? = null) : SealedLLMStreamResponse
}
@Serializable data class Source(val id: String? = null, val text: String? = null, val location: String? = null, val distance: Float? = null, val score: Float? = null)
@Serializable data class Metrics(@SerialName("completion_tokens") val completionTokens: Int, @SerialName("prompt_tokens") val promptTokens: Int, @SerialName("total_tokens") val totalTokens: Int, val outputTps: Double? = null, val duration: Double? = null)
// 기존 API에서 사용하는 데이터 클래스들
@Serializable data class AnythingLLMChatRequest(val message: String)
@Serializable data class AnythingLLMChatResponse(val textResponse: String)
@Serializable data class UploadResponse(val documents: List<Document>) { @Serializable data class Document(val id: String, val location: String) }
@Serializable data class DeleteDocumentsRequest(val deletes: List<String>)
data class SearchResult(val title: String, val url: String)
@Serializable
data class ScrapedData(val sourceUrl: String, val selectedImageUrls: List<String>, val allImageUrls: List<String>, val content: String)
// --- 전역 변수 및 헬퍼 ---
private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) }
install(HttpTimeout) { requestTimeoutMillis = 900000 }
}
private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = true }
private var driver: ChromeDriver? = null
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
private const val PREF_FOLDER_PATH = "folder_path"
private const val PREF_API_KEY = "api_key"
private const val PREF_WORKSPACE_SLUG = "workspace_slug"
private const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
private const val PREF_MODEL_NAME = "model_name"
fun logMessage(logs: MutableList<String>, message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
logs.add(0, "$timestamp: $message")
}
// --- 브라우저 관리 ---
private fun getChromeDriver(options: ChromeOptions): ChromeDriver {
try { driver?.title } catch (e: WebDriverException) { driver = null }
if (driver == null) { driver = ChromeDriver(options) }
return driver!!
}
private fun quitChromeDriver() { driver?.quit(); driver = null }
// --- 핵심 기능 함수들 (스크랩, 업로드 등) ---
suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<String> {
logMessage(logs, "Google Trends 페이지 스크랩 시작...")
val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR"
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = getChromeDriver(options)
return try {
currentDriver.get(trendsUrl)
Thread.sleep(5000)
currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")).mapNotNull { it.text.takeIf(String::isNotBlank) }.also { logMessage(logs, "✅ Google Trends 키워드 ${it.size}개 스크랩 완료.") }
} catch (e: Exception) {
logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}"); quitChromeDriver(); emptyList()
} finally { if (!keepSession) quitChromeDriver() }
}
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> {
logMessage(logs, "'$keyword' 키워드로 Google 검색 시작...")
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = getChromeDriver(options)
val results = mutableListOf<SearchResult>()
try {
currentDriver.get("https://www.google.com/search?q=$keyword")
Thread.sleep(10000)
currentDriver.findElements(By.cssSelector("div[data-rpos]")).take(10).forEach { element ->
try {
val title = element.findElement(By.cssSelector("h3")).text
val url = element.findElement(By.cssSelector("a")).getAttribute("href")
if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url))
} catch (e: Exception) { /* 개별 오류 무시 */ }
}
logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.")
} catch (e: Exception) {
logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); quitChromeDriver()
} finally { if (!keepSession) quitChromeDriver() }
return results
}
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? {
logMessage(logs, "URL 스크랩 시작: $url")
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = getChromeDriver(options)
return try {
currentDriver.get(url)
Thread.sleep(2000)
val doc = Jsoup.parse(currentDriver.pageSource)
val articleContent = doc.select("#app, .app, article, .article-body, #article_body, .news-article-body-view").text()
val allImages = doc.select("article img, .article-body img, #article_body img, .news-article-body-view img").map { it.absUrl("src") }.filter { it.isNotBlank() && it.startsWith("http") }.distinct()
if (articleContent.isBlank()) { logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다."); null }
else { logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)"); ScrapedData(sourceUrl = url, selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent) }
} catch (e: Exception) {
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}"); quitChromeDriver(); null
} finally { if (!keepSession) quitChromeDriver() }
}
suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> {
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
val uploadedDocIds = mutableListOf<String>()
files.forEach { file ->
logMessage(logs, " - '${file.name}' 업로드 중...")
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/upload") {
header("Authorization", "Bearer $apiKey")
setBody(MultiPartFormDataContent(formData {
append("addToWorkspaces", workspaceSlug)
val scrapedData = jsonParser.decodeFromString<ScrapedData>(file.readText())
append("file", scrapedData.content.toByteArray(), Headers.build { append(HttpHeaders.ContentType, ContentType.Text.Plain); append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") })
}))
}
if (response.status.isSuccess()) {
val uploadResponse = response.body<UploadResponse>()
uploadResponse.documents.firstOrNull()?.id?.let { uploadedDocIds.add(it); logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).") } ?: logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
} else { logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}") }
} catch (e: Exception) { logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}") }
}
logMessage(logs, "${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.")
return uploadedDocIds
}
suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, userOwnContent: String, allSelectedImages: List<String>, userDirection: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
logMessage(logs, "LLM 블로그 글 생성 요청...")
val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) { scrapedDataList.mapIndexed { index, data -> "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" }.joinToString("\n").let { "--- 참고자료 ---\n$it" } } else { "--- 주요 내용 ---\n아래 내용을 바탕으로 글을 작성해주세요.\n$userOwnContent" }
val imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n")}" else ""
val finalPrompt = """당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. $contentSourcePromptPart --- 요청사항 ---\n1. 사용자 요청: "$userDirection"\n2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.\n3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요. $imagePromptPart\n5. 글 마지막에 출처에 대한 언급은 절대 하지 마세요."""
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
}
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
return chatResponse.textResponse
} catch (e: Exception) {
logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}"); return "블로그 글 생성 실패: ${e.message}"
}
}
suspend fun cleanupLLMWorkspace(docIds: List<String>, logs: MutableList<String>, apiKey: String) {
if (docIds.isEmpty()) return
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...")
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(DeleteDocumentsRequest(deletes = docIds))
}
if (response.status.isSuccess()) { logMessage(logs, "✅ LLM 워크스페이스 정리 완료.") }
else { logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}") }
} catch (e: Exception) { logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}") }
}
@Serializable data class Attachment(val name: String, val mime: String, val contentString: String)
@Serializable data class StreamChatRequest(val message: String, val attachments: List<Attachment>, val mode: String, val sessionId: String)
suspend fun analyzeReceiptsWithStreamChat(files: List<File>, apiKey: String, receiptWorkspaceSlug: String, logs: MutableList<String>, resultState: MutableStateFlow<String>, receiptContext: String) {
if (apiKey.isBlank() || receiptWorkspaceSlug.isBlank() || files.isEmpty()) {
val missing = listOfNotNull(if (apiKey.isBlank()) "API Key" else null, if (receiptWorkspaceSlug.isBlank()) "영수증 Workspace" else null, if (files.isEmpty()) "영수증 파일" else null).joinToString(); logs.add("⚠️ 영수증 분석을 시작할 수 없습니다. ($missing 누락)"); return
}
logMessage(logs, "Starting receipt analysis with stream-chat mode...")
resultState.value = "영수증 분석 중..."
try {
val attachments = files.map { file -> val base64Image = Base64.getEncoder().encodeToString(file.readBytes()); val mimeType = when (file.extension.lowercase()) { "png" -> "image/png"; "jpeg", "jpg" -> "image/jpeg"; else -> "application/octet-stream" }; Attachment(file.name, mimeType, "data:$mimeType;base64,$base64Image") }
val basePrompt = "첨부된 영수증 이미지들을 분석해줘."
val finalPrompt = if (receiptContext.isNotBlank()) """$basePrompt --- 추가 정보 --- $receiptContext --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘.""" else basePrompt
logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}")
val requestBody = StreamChatRequest(message = finalPrompt, attachments = attachments, mode = "chat", sessionId = "receipt-analysis-${System.currentTimeMillis()}")
httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") {
accept(ContentType.Application.Json); header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(requestBody)
}.execute { response ->
if (response.status.isSuccess()) {
val channel: ByteReadChannel = response.bodyAsChannel()
var fullResponseText = ""
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: continue
if (line.startsWith("data:")) {
val jsonChunk = line.removePrefix("data:").trim()
if (jsonChunk.isEmpty() || jsonChunk == "[DONE]") continue
try {
when (val streamObject = jsonParser.decodeFromString<SealedLLMStreamResponse>(jsonChunk)) {
is SealedLLMStreamResponse.TextResponseChunk -> { val textChunk = streamObject.textResponse ?: ""; if (textChunk.isNotBlank() && textChunk != "-") { fullResponseText += textChunk; resultState.value = fullResponseText } }
is SealedLLMStreamResponse.FinalizeResponseStream -> { logMessage(logs, "✅ Stream finalized."); streamObject.metrics?.let { logs.add(" - Metrics: total_tokens=${it.totalTokens}, duration=${it.duration}s") }; break }
is SealedLLMStreamResponse.AbortResponse -> { logMessage(logs, "⚠️ Stream aborted by server. Reason: ${streamObject.textResponse}"); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break }
}
} catch (e: Exception) { logMessage(logs, "❌ Chunk parsing error: ${e.message} | Chunk: $jsonChunk") }
}
}
logMessage(logs, "Receipt analysis stream processing finished.")
} else { val errorBody = response.bodyAsText(); logMessage(logs, "❌ Error during receipt analysis: ${response.status} - $errorBody"); resultState.value = "API 오류: ${response.status}" }
}
} catch (e: CancellationException) { throw e }
catch (e: Exception) { logMessage(logs, "❌ Exception in receipt analysis: ${e.message}"); resultState.value = e.message ?: "알 수 없는 오류" }
}
fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList<String>, folderPath: String): File {
val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() }
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName)
file.writeText(jsonParser.encodeToString(data))
logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.")
return file
}
fun loadScrapedJsonFiles(logs: MutableList<String>, folderPath: String): List<File> {
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
return File(folderPath).listFiles { _, name -> name.endsWith(".json") }?.sortedByDescending { it.lastModified() }.orEmpty().also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") }
}
@Composable
fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List<File>) -> Unit) {
val fileDialog = remember { FileDialog(parent, "파일 선택", FileDialog.LOAD).apply { isMultipleMode = true } }
LaunchedEffect(Unit) { fileDialog.isVisible = true; onCloseRequest(fileDialog.files.toList()) }
}
// --- UI 컴포넌트 ---
@Composable
fun App(imageLoader: ImageLoader) {
val coroutineScope = rememberCoroutineScope()
var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("워크플로우", "통신 로그", "블로그 결과")
var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
val logMessages = remember { mutableStateListOf<String>() }
var isLoading by remember { mutableStateOf(false) }
var selectedKeyword by remember { mutableStateOf("") }
var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") }
var isBrowserVisible by remember { mutableStateOf(true) }
var keepBrowserSession by remember { mutableStateOf(true) }
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) }
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my-workspace")) }
var receiptWorkspaceSlug by remember { mutableStateOf(prefs.get(PREF_RECEIPT_WORKSPACE_SLUG, "receipts")) }
var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "Llama-3.1-8B-Vision")) }
var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
var imagesForSelection by remember { mutableStateOf<List<String>>(emptyList()) }
var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) }
var manualKeyword by remember { mutableStateOf("") }
var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다.") }
var isImageUploadDialogVisible by remember { mutableStateOf(false) }
var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") }
var isReceiptDialogVisible by remember { mutableStateOf(false) }
var analysisJob by remember { mutableStateOf<Job?>(null) }
var receiptContextPrompt by remember { mutableStateOf("") }
LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath); prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) }
LaunchedEffect(apiKey) { prefs.put(PREF_API_KEY, apiKey) }
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) }
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
if (isImageUploadDialogVisible) { FileDialog { selected -> isImageUploadDialogVisible = false; if (selected.isNotEmpty()) { uploadedImageFiles = uploadedImageFiles + selected; logMessage(logMessages, "✅ 개인 이미지 파일 ${selected.size}개 추가됨.") } } }
if (isReceiptDialogVisible) { FileDialog { selected -> isReceiptDialogVisible = false; if (selected.isNotEmpty()) { receiptFiles = receiptFiles + selected; logMessage(logMessages, "🧾 영수증 이미지 ${selected.size}개 추가됨.") } } }
MaterialTheme {
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it }); Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f)); Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it }); Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f)) }
Spacer(Modifier.height(8.dp)); OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true); Spacer(Modifier.height(4.dp)); OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation()); Spacer(Modifier.height(4.dp))
Row(Modifier.fillMaxWidth()) { OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("블로그용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true); Spacer(Modifier.width(4.dp)); OutlinedTextField(value = receiptWorkspaceSlug, onValueChange = { receiptWorkspaceSlug = it }, label = { Text("영수증 처리용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true) }
Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("사용 중인 LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
}
Divider()
TabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } }
when (tabIndex) {
0 -> WorkflowTab(
isLoading = isLoading, keywords = keywords, searchResults = searchResults, scrapedFiles = scrapedFiles, selectedFiles = selectedFiles,
viewedFileContent = viewedFileContent, imagesForSelection = imagesForSelection, currentSelectedImages = currentSelectedImages,
userPrompt = userPrompt, onUserPromptChange = { userPrompt = it }, imageLoader = imageLoader,
manualKeyword = manualKeyword, onManualKeywordChange = { manualKeyword = it },
userOwnContent = userOwnContent, onUserOwnContentChange = { userOwnContent = it },
uploadedImageFiles = uploadedImageFiles,
onFetchTrends = { coroutineScope.launch(Dispatchers.IO) { isLoading = true; keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
onKeywordSelect = { keyword -> selectedKeyword = keyword; manualKeyword = keyword; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
onSearchResultSelect = { result -> coroutineScope.launch(Dispatchers.IO) { isLoading = true; scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)?.let { data -> val finalKeyword = if (selectedKeyword.isNotBlank()) selectedKeyword else manualKeyword; val savedFile = saveDataToJsonFile(finalKeyword, data, logMessages, scrapedFolderPath); scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath); currentlyOpenFile = savedFile; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() }; isLoading = false } },
onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) } },
onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file },
onFileView = { file -> coroutineScope.launch(Dispatchers.IO) { try { val data = jsonParser.decodeFromString<ScrapedData>(file.readText()); currentlyOpenFile = file; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") } } },
onImageSelect = { imageUrl -> currentSelectedImages = if (imageUrl in currentSelectedImages) currentSelectedImages - imageUrl else currentSelectedImages + imageUrl },
onSaveChanges = { coroutineScope.launch(Dispatchers.IO) { currentlyOpenFile?.let { file -> try { val originalData = jsonParser.decodeFromString<ScrapedData>(file.readText()); val updatedData = originalData.copy(selectedImageUrls = currentSelectedImages.toList()); file.writeText(jsonParser.encodeToString(updatedData)); logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.") } catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") } } } },
onUploadImage = { isImageUploadDialogVisible = true },
onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove },
onGeneratePost = { if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@WorkflowTab }; coroutineScope.launch(Dispatchers.IO) { isLoading = true; try { val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }; val isScrapBased = selectedFiles.isNotEmpty(); val finalContent = if(isScrapBased) selectedDataList else emptyList(); val finalUserContent = if(!isScrapBased) userOwnContent else ""; val allImages = if(isScrapBased) selectedDataList.flatMap { it.selectedImageUrls }.distinct() else uploadedImageFiles.map { it.toURI().toString() }; if (!isScrapBased && (userOwnContent.isBlank() || allImages.isEmpty())) { logMessage(logMessages, "⚠️ 직접 포스팅을 하려면 '직접 작성' 내용과 '업로드한 이미지'가 모두 필요합니다."); return@launch }; val uploadedDocIds = if (isScrapBased) uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug).also { if (it.isEmpty()) { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다."); return@launch } } else emptyList(); val resultText = generateBlogPostWithLocalLLM(finalContent, finalUserContent, allImages, userPrompt, logMessages, apiKey, workspaceSlug); val footer = buildString { appendLine("\n\n---"); if (isScrapBased) { appendLine("- 원문 출처:"); finalContent.forEach { appendLine(" - ${it.sourceUrl}") } }; if(allImages.isNotEmpty()){ appendLine("- 사용된 이미지:"); allImages.forEach { appendLine(" - $it")} }; appendLine("- 이 글은 ${modelName} 모델을 활용하여 작성되었습니다.") }; blogPostResult = resultText + footer; tabIndex = 2; if (isScrapBased) cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey) } finally { isLoading = false } } },
receiptFiles = receiptFiles,
receiptAnalysisResult = receiptAnalysisResult,
onUploadReceipt = { isReceiptDialogVisible = true },
onRemoveReceipt = { file -> receiptFiles = receiptFiles - file },
isAnalyzing = analysisJob?.isActive == true,
onAnalyzeReceipts = {
analysisJob = coroutineScope.launch(Dispatchers.IO) {
try {
isLoading = true
val resultFlow = MutableStateFlow("")
val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } }
analyzeReceiptsWithStreamChat(receiptFiles, apiKey, receiptWorkspaceSlug, logMessages, resultFlow, receiptContextPrompt)
uiUpdateJob.cancel()
} catch (e: CancellationException) { logMessage(logMessages, " 영수증 분석이 사용자에 의해 중단되었습니다."); receiptAnalysisResult = "분석이 중단되었습니다."
} catch (e: Exception) { logMessage(logMessages, "❌ 영수증 분석 중 심각한 오류 발생: ${e.message}"); receiptAnalysisResult = "오류 발생: ${e.message}"
} finally { isLoading = false; analysisJob = null }
}
},
onCancelAnalysis = { analysisJob?.cancel() },
receiptContextPrompt = receiptContextPrompt,
onReceiptContextPromptChange = { receiptContextPrompt = it }
)
1 -> LogTab(logMessages)
2 -> ResultTab(blogPostResult)
}
}
}
}
@Composable
fun WorkflowTab(
isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>, scrapedFiles: List<File>, selectedFiles: Set<File>,
viewedFileContent: String, imagesForSelection: List<String>, currentSelectedImages: Set<String>,
userPrompt: String, imageLoader: ImageLoader,
manualKeyword: String, onManualKeywordChange: (String) -> Unit,
userOwnContent: String, onUserOwnContentChange: (String) -> Unit,
uploadedImageFiles: List<File>,
onUserPromptChange: (String) -> Unit, onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit,
onSearchResultSelect: (SearchResult) -> Unit, onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit,
onFileView: (File) -> Unit, onImageSelect: (String) -> Unit, onSaveChanges: () -> Unit, onGeneratePost: () -> Unit,
onUploadImage: () -> Unit, onRemoveUploadedImage: (File) -> Unit,
receiptFiles: List<File>,
receiptAnalysisResult: String,
onUploadReceipt: () -> Unit,
onRemoveReceipt: (File) -> Unit,
onAnalyzeReceipts: () -> Unit,
isAnalyzing: Boolean,
onCancelAnalysis: () -> Unit,
receiptContextPrompt: String,
onReceiptContextPromptChange: (String) -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(4.dp)) {
Row(modifier = Modifier.fillMaxWidth()) { OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text("키워드 직접 입력") }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading); Spacer(Modifier.width(4.dp)); Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text("검색") } }; Spacer(Modifier.height(8.dp)); Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") }; Divider(modifier = Modifier.padding(vertical = 8.dp)); LazyColumn(modifier = Modifier.fillMaxSize()) { items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) } }
}
Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) {
Text("검색 결과", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); LazyColumn(modifier = Modifier.fillMaxSize()) { items(searchResults) { result -> Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) { Text(result.title, style = MaterialTheme.typography.subtitle1, color = MaterialTheme.colors.primary); Text(result.url, style = MaterialTheme.typography.caption, maxLines = 1) } } }
}
Column(modifier = Modifier.weight(3f).padding(horizontal = 4.dp).verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Text("저장된 파일 (스크랩 기반)", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp)); Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") } }; Box(modifier = Modifier.heightIn(max = 200.dp)) { LazyColumn { items(scrapedFiles) { file -> Row(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = file in selectedFiles, onCheckedChange = { isChecked -> onFileSelectToggle(file, isChecked) }, enabled = !isLoading); Text(file.name, modifier = Modifier.padding(start = 4.dp), maxLines = 1) } } } }; Spacer(Modifier.height(8.dp)); Text("파일 내용", style = MaterialTheme.typography.subtitle1); Text(text = viewedFileContent, modifier = Modifier.height(100.dp).fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp).verticalScroll(rememberScrollState()), style = MaterialTheme.typography.body2); Spacer(Modifier.height(8.dp)); Row(verticalAlignment = Alignment.CenterVertically) { Text("대표 이미지 선택 (다중 가능)", style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f)); Button(onClick = onSaveChanges, enabled = !isLoading && imagesForSelection.isNotEmpty()) { Text("선택 이미지 저장") } }; LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) { items(imagesForSelection) { imageUrl -> val isSelected = imageUrl in currentSelectedImages; Box(modifier = Modifier.padding(4.dp)) { Image(painter = rememberAsyncImagePainter(model = imageUrl, imageLoader = imageLoader), contentDescription = "Scraped Image", modifier = Modifier.size(100.dp).clickable { onImageSelect(imageUrl) }.border(if (isSelected) 4.dp else 0.dp, MaterialTheme.colors.primary), contentScale = ContentScale.Crop) } } }
}
Spacer(Modifier.height(16.dp))
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Text("직접 작성 (여행 기록 등)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); OutlinedTextField(value = userOwnContent, onValueChange = onUserOwnContentChange, modifier = Modifier.fillMaxWidth().height(150.dp), label = { Text("블로그에 올릴 내용을 직접 작성하세요.") }); Spacer(Modifier.height(8.dp)); Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("내 PC에서 이미지 업로드") }; LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { items(uploadedImageFiles) { file -> Box(modifier = Modifier.padding(4.dp)) { Image(painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader), contentDescription = "Uploaded Image", modifier = Modifier.size(100.dp).clickable { onRemoveUploadedImage(file) }, contentScale = ContentScale.Crop) } } }
}
Spacer(Modifier.height(16.dp))
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Text("영수증 분석기", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("영수증 이미지 업로드") }; LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { items(receiptFiles) { file -> Box(modifier = Modifier.padding(4.dp)) { Image(painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader), contentDescription = "Receipt Image", modifier = Modifier.size(100.dp).clickable { onRemoveReceipt(file) }, contentScale = ContentScale.Crop) } } }; Spacer(Modifier.height(8.dp));
OutlinedTextField(value = receiptContextPrompt, onValueChange = onReceiptContextPromptChange, modifier = Modifier.fillMaxWidth(), placeholder = { Text("추가 정보를 입력하면 더 정확하게 분석할 수 있습니다.") }, label = { Text("추가 정보 입력 (예: 부산 출장 경비)") })
Spacer(Modifier.height(8.dp));
if (isAnalyzing) {
Button(onClick = onCancelAnalysis, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error)) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp); Spacer(Modifier.width(8.dp)); Text("분석 중단하기") } }
} else {
Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { Text("선택한 영수증 분석 시작 (${receiptFiles.size}개)") }
}
Spacer(Modifier.height(8.dp)); OutlinedTextField(value = receiptAnalysisResult, onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth().height(180.dp), label = { Text("분석 결과 (내용 복사하여 사용)") })
}
}
Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(8.dp)) {
Text("LLM 요청사항", style = MaterialTheme.typography.h6); TextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), placeholder = { Text("예: 선택된 파일들을 종합해서...") }); Spacer(Modifier.height(8.dp)); Button(onClick = onGeneratePost, enabled = !isLoading && (selectedFiles.isNotEmpty() || (userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty())), modifier = Modifier.fillMaxWidth()) { val buttonText = if (selectedFiles.isNotEmpty()) "스크랩 기반 글 생성 (${selectedFiles.size}개 파일)" else "직접 작성 기반 글 생성"; Text(buttonText) }
}
}
if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
}
}
@Composable
fun LogTab(logs: List<String>) {
TextField(value = logs.joinToString("\n"), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize().padding(8.dp))
}
@Composable
fun ResultTab(result: String) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { Text(result) }
}
fun main() = application {
val imageLoader = ImageLoader.Builder(coil3.PlatformContext.INSTANCE)
@ -457,7 +65,7 @@ fun main() = application {
.build()
Window(
onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() },
title = "자동 블로그 포스팅 도우미 v9.0 (Context-Aware)"
title = "자동 블로그 포스팅 도우미 v14.0 (Contextual Footer)" // ⭐️ [수정] 버전명 변경
) {
App(imageLoader)
}

View File

@ -0,0 +1,27 @@
// core/BrowserManager.kt
package core
import org.openqa.selenium.WebDriverException
import org.openqa.selenium.chrome.ChromeDriver
import org.openqa.selenium.chrome.ChromeOptions
object BrowserManager {
private var driver: ChromeDriver? = null
fun getChromeDriver(options: ChromeOptions): ChromeDriver {
try {
driver?.title // Check if the driver is still active
} catch (e: WebDriverException) {
driver = null // Driver is dead, so nullify it
}
if (driver == null) {
driver = ChromeDriver(options)
}
return driver!!
}
fun quitChromeDriver() {
driver?.quit()
driver = null
}
}

View File

@ -0,0 +1,29 @@
// core/FileManager.kt
package core
import kotlinx.serialization.encodeToString
import models.ScrapedData
import utils.Global
import utils.logMessage
import java.io.File
object FileManager {
fun saveDataToJsonFile(keyword: String, data: ScrapedData, folderPath: String, logs: MutableList<String>): File {
val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() }
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName)
file.writeText(Global.jsonParser.encodeToString(data))
logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.")
return file
}
fun loadScrapedJsonFiles(folderPath: String, logs: MutableList<String>): List<File> {
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
return File(folderPath).listFiles { _, name -> name.endsWith(".json") }
?.sortedByDescending { it.lastModified() }
.orEmpty()
.also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") }
}
}

View File

@ -0,0 +1,195 @@
// core/LlmApiService.kt
package core
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.utils.io.*
import kotlinx.coroutines.flow.MutableStateFlow
import models.*
import utils.Global
import utils.Global.httpClient
import utils.Global.jsonParser
import utils.logMessage
import java.io.File
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
object LlmApiService {
suspend fun uploadFiles(files: List<File>, apiKey: String, workspaceSlug: String, logs: MutableList<String>): List<String> {
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
val uploadedDocIds = mutableListOf<String>()
files.forEach { file ->
logMessage(logs, " - '${file.name}' 업로드 중...")
try {
val response: HttpResponse = Global.httpClient.post("http://localhost:3001/api/v1/document/upload") {
header("Authorization", "Bearer $apiKey")
setBody(MultiPartFormDataContent(formData {
append("addToWorkspaces", workspaceSlug)
val scrapedData = Global.jsonParser.decodeFromString<ScrapedData>(file.readText())
append("file", scrapedData.content.toByteArray(), Headers.build { append(HttpHeaders.ContentType, ContentType.Text.Plain); append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") })
}))
}
if (response.status.isSuccess()) {
val uploadResponse = response.body<UploadResponse>()
uploadResponse.documents.firstOrNull()?.id?.let { uploadedDocIds.add(it); logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).") } ?: logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
} else { logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}") }
} catch (e: Exception) { logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}") }
}
logMessage(logs, "${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.")
return uploadedDocIds
}
suspend fun generateBlogPost(
scrapedDataList: List<ScrapedData>,
userOwnContent: String,
allSelectedImages: List<String>,
userDirection: String,
userScrapComment: String,
userMainTopic: String,
apiKey: String,
workspaceSlug: String,
logs: MutableList<String>
): String {
logMessage(logs, "LLM 블로그 글 생성 요청...")
val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) {
scrapedDataList.mapIndexed { index, data -> "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" }.joinToString("\n").let { "--- 참고자료 ---\n$it" }
} else {
"--- 주요 내용 ---\n아래 내용을 바탕으로 글을 작성해주세요.\n$userOwnContent"
}
val commentPromptPart = if (userScrapComment.isNotBlank()) {
"--- 작성자 코멘트 ---\n$userScrapComment\n이 코멘트의 논조와 생각을 반영하여 글을 작성해주세요.\n"
} else ""
val mainTopicPromptPart = if (userMainTopic.isNotBlank()) {
"--- 글의 핵심 주제 ---\n$userMainTopic\n이 주제가 글의 중심 내용이 되도록 작성해주세요.\n"
} else ""
val imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n")}" else ""
val finalPrompt = """
당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요.
$contentSourcePromptPart
$commentPromptPart
$mainTopicPromptPart
--- 요청사항 ---
1. 사용자 요청: "$userDirection"
2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3 추천해주세요.
$imagePromptPart
5. 가능한 자연스럽고 유머러스하게 글을 작성해주세요.
6. 주제에 벗어나지 않고 해당 주제에 포커스를 맞춰서 글을 작성해주세요.
""".trimIndent()
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
}
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
return chatResponse.textResponse
} catch (e: Exception) {
logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}"); return "블로그 글 생성 실패: ${e.message}"
}
}
suspend fun reviseBlogPost(
currentPost: String,
revisionRequest: String,
apiKey: String,
workspaceSlug: String,
logs: MutableList<String>
): String {
logMessage(logs, "LLM 블로그 글 수정 요청...")
if (revisionRequest.isBlank()) {
logMessage(logs, "⚠️ 수정 요청사항이 비어있어 수정을 중단합니다.")
return currentPost
}
val finalPrompt = """
당신은 주어진 글을 사용자의 요청에 맞게 수정하는 전문 편집자입니다. 아래의 원본 글을 수정 요청사항에 따라 개선해주세요. 원본의 주제와 주요 내용은 유지하되, 요청을 충실히 반영하여 나은 글로 만들어주세요. 마크다운 형식은 유지해주세요.
--- 원본 ---
$currentPost
--- 수정 요청사항 ---
$revisionRequest
--- 수정된 ---
""".trimIndent()
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
}
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
logMessage(logs, "✅ LLM 블로그 글 수정 완료.")
return chatResponse.textResponse
} catch (e: Exception) {
logMessage(logs, "❌ LLM 수정 호출 중 오류: ${e.message}"); return "블로그 글 수정 실패: ${e.message}"
}
}
suspend fun cleanupWorkspace(docIds: List<String>, apiKey: String, logs: MutableList<String>) {
if (docIds.isEmpty()) return
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...")
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(DeleteDocumentsRequest(deletes = docIds))
}
if (response.status.isSuccess()) { logMessage(logs, "✅ LLM 워크스페이스 정리 완료.") }
else { logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}") }
} catch (e: Exception) { logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}") }
}
suspend fun analyzeReceipts(
files: List<File>,
apiKey: String,
receiptWorkspaceSlug: String,
receiptContext: String,
logs: MutableList<String>,
resultState: MutableStateFlow<String>
) {
if (apiKey.isBlank() || receiptWorkspaceSlug.isBlank() || files.isEmpty()) {
val missing = listOfNotNull(if (apiKey.isBlank()) "API Key" else null, if (receiptWorkspaceSlug.isBlank()) "영수증 Workspace" else null, if (files.isEmpty()) "영수증 파일" else null).joinToString(); logs.add("⚠️ 영수증 분석을 시작할 수 없습니다. ($missing 누락)"); return
}
logMessage(logs, "Starting receipt analysis with stream-chat mode...")
resultState.value = "영수증 분석 중..."
try {
val attachments = files.map { file -> val base64Image = Base64.getEncoder().encodeToString(file.readBytes()); val mimeType = when (file.extension.lowercase()) { "png" -> "image/png"; "jpeg", "jpg" -> "image/jpeg"; else -> "application/octet-stream" }; Attachment(file.name, mimeType, "data:$mimeType;base64,$base64Image") }
val basePrompt = "첨부된 영수증 이미지들을 분석해줘."
val finalPrompt = if (receiptContext.isNotBlank()) """$basePrompt --- 추가 정보 --- $receiptContext --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘.""" else basePrompt
logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}")
val requestBody = StreamChatRequest(message = finalPrompt, attachments = attachments, mode = "chat", sessionId = "receipt-analysis-${System.currentTimeMillis()}")
httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") {
accept(ContentType.Application.Json); header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(requestBody)
}.execute { response ->
if (response.status.isSuccess()) {
val channel: ByteReadChannel = response.bodyAsChannel()
var fullResponseText = ""
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: continue
if (line.startsWith("data:")) {
val jsonChunk = line.removePrefix("data:").trim()
if (jsonChunk.isEmpty() || jsonChunk == "[DONE]") continue
try {
when (val streamObject = jsonParser.decodeFromString<SealedLLMStreamResponse>(jsonChunk)) {
is SealedLLMStreamResponse.TextResponseChunk -> { val textChunk = streamObject.textResponse ?: ""; if (textChunk.isNotBlank() && textChunk != "-") { fullResponseText += textChunk; resultState.value = fullResponseText } }
is SealedLLMStreamResponse.FinalizeResponseStream -> { logMessage(logs, "✅ Stream finalized."); streamObject.metrics?.let { logs.add(" - Metrics: total_tokens=${it.totalTokens}, duration=${it.duration}s") }; break }
is SealedLLMStreamResponse.AbortResponse -> { logMessage(logs, "⚠️ Stream aborted by server. Reason: ${streamObject.textResponse}"); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break }
}
} catch (e: Exception) { logMessage(logs, "❌ Chunk parsing error: ${e.message} | Chunk: $jsonChunk") }
}
}
logMessage(logs, "Receipt analysis stream processing finished.")
} else { val errorBody = response.bodyAsText(); logMessage(logs, "❌ Error during receipt analysis: ${response.status} - $errorBody"); resultState.value = "API 오류: ${response.status}" }
}
} catch (e: CancellationException) { throw e }
catch (e: Exception) { logMessage(logs, "❌ Exception in receipt analysis: ${e.message}"); resultState.value = e.message ?: "알 수 없는 오류" }
}
}

View File

@ -0,0 +1,65 @@
// core/ScrapingService.kt
package core
import models.SearchResult
import models.ScrapedData
import org.jsoup.Jsoup
import org.openqa.selenium.By
import org.openqa.selenium.chrome.ChromeOptions
import utils.logMessage
object ScrapingService {
suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<String> {
logMessage(logs, "Google Trends 페이지 스크랩 시작...")
val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR"
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options)
return try {
currentDriver.get(trendsUrl)
Thread.sleep(5000)
currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")).mapNotNull { it.text.takeIf(String::isNotBlank) }.also { logMessage(logs, "✅ Google Trends 키워드 ${it.size}개 스크랩 완료.") }
} catch (e: Exception) {
logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}"); BrowserManager.quitChromeDriver(); emptyList()
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
}
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> {
logMessage(logs, "'$keyword' 키워드로 Google 검색 시작...")
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options)
val results = mutableListOf<SearchResult>()
try {
currentDriver.get("https://www.google.com/search?q=$keyword")
Thread.sleep(10000)
currentDriver.findElements(By.cssSelector("div[data-rpos]")).take(10).forEach { element ->
try {
val title = element.findElement(By.cssSelector("h3")).text
val url = element.findElement(By.cssSelector("a")).getAttribute("href")
if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url))
} catch (e: Exception) { /* 개별 오류 무시 */ }
}
logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.")
} catch (e: Exception) {
logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); BrowserManager.quitChromeDriver()
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
return results
}
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? {
logMessage(logs, "URL 스크랩 시작: $url")
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options)
return try {
currentDriver.get(url)
Thread.sleep(2000)
val doc = Jsoup.parse(currentDriver.pageSource)
val articleContent = doc.select("#app, .app, article, .article-body, #article_body, .news-article-body-view").text()
val allImages = doc.select("article img, .article-body img, #article_body img, .news-article-body-view img").map { it.absUrl("src") }.filter { it.isNotBlank() && it.startsWith("http") }.distinct()
if (articleContent.isBlank()) { logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다."); null }
else { logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)"); ScrapedData(sourceUrl = url, selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent) }
} catch (e: Exception) {
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}"); BrowserManager.quitChromeDriver(); null
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
}
}

View File

@ -0,0 +1,36 @@
// models/ApiData.kt
package models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator
// --- Stream Chat API 응답 모델 ---
@Serializable
@JsonClassDiscriminator("type")
sealed interface SealedLLMStreamResponse {
@Serializable
@SerialName("textResponseChunk")
data class TextResponseChunk(val uuid: String, val textResponse: String?, val sources: List<Source> = emptyList(), val close: Boolean, val error: Boolean) : SealedLLMStreamResponse
@Serializable
@SerialName("finalizeResponseStream")
data class FinalizeResponseStream(val uuid: String, val chatId: Int, val metrics: Metrics?, val close: Boolean, val error: Boolean) : SealedLLMStreamResponse
@Serializable
@SerialName("abort")
data class AbortResponse(val uuid: String? = null, val textResponse: String? = null, val close: Boolean = true, val error: Boolean? = null) : SealedLLMStreamResponse
}
@Serializable data class Source(val id: String? = null, val text: String? = null, val location: String? = null, val distance: Float? = null, val score: Float? = null)
@Serializable data class Metrics(@SerialName("completion_tokens") val completionTokens: Int, @SerialName("prompt_tokens") val promptTokens: Int, @SerialName("total_tokens") val totalTokens: Int, val outputTps: Double? = null, val duration: Double? = null)
// --- 일반 Chat 및 문서 관리 API 모델 ---
@Serializable data class AnythingLLMChatRequest(val message: String)
@Serializable data class AnythingLLMChatResponse(val textResponse: String)
@Serializable data class UploadResponse(val documents: List<Document>) { @Serializable data class Document(val id: String, val location: String) }
@Serializable data class DeleteDocumentsRequest(val deletes: List<String>)
// --- Stream Chat API 요청 모델 ---
@Serializable data class Attachment(val name: String, val mime: String, @SerialName("contentString") val content: String)
@Serializable data class StreamChatRequest(val message: String, val attachments: List<Attachment>, val mode: String, val sessionId: String)

View File

@ -0,0 +1,14 @@
// models/AppData.kt
package models
import kotlinx.serialization.Serializable
data class SearchResult(val title: String, val url: String)
@Serializable
data class ScrapedData(
val sourceUrl: String,
val selectedImageUrls: List<String>,
val allImageUrls: List<String>,
val content: String
)

266
src/main/kotlin/ui/App.kt Normal file
View File

@ -0,0 +1,266 @@
// ui/App.kt
package ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import coil3.ImageLoader
import core.FileManager
import core.FileManager.loadScrapedJsonFiles
import core.FileManager.saveDataToJsonFile
import core.LlmApiService
import core.LlmApiService.analyzeReceipts
import core.LlmApiService.cleanupWorkspace
import core.LlmApiService.generateBlogPost
import core.LlmApiService.reviseBlogPost
import core.LlmApiService.uploadFiles
import core.ScrapingService
import core.ScrapingService.fetchGoogleTrends
import core.ScrapingService.scrapeArticleByUrl
import core.ScrapingService.searchOnGoogle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import models.ScrapedData
import models.SearchResult
import ui.tabs.*
import ui.widgets.FileDialog
import utils.Global.jsonParser
import utils.logMessage
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.io.File
import java.util.prefs.Preferences
import kotlin.coroutines.cancellation.CancellationException
import kotlin.text.isBlank
// (기존 App Composable의 상태 변수 선언 및 로직을 여기에 모두 이동)
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
const val PREF_FOLDER_PATH = "folder_path"
const val PREF_API_KEY = "api_key"
const val PREF_WORKSPACE_SLUG = "workspace_slug"
const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
const val PREF_MODEL_NAME = "model_name"
// --- UI 컴포넌트 ---
@Composable
fun App(imageLoader: ImageLoader) {
val coroutineScope = rememberCoroutineScope()
var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("스크랩 기반 포스팅", "직접 포스팅", "영수증 분석기", "통신 로그", "블로그 결과")
var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
var blogPostFooter by remember { mutableStateOf("") }
val logMessages = remember { mutableStateListOf<String>() }
var isLoading by remember { mutableStateOf(false) }
var selectedKeyword by remember { mutableStateOf("") }
var userPrompt by remember { mutableStateOf("개인 기록용으로 가볍게 남기는 스타일로 작성해줘.") }
var isBrowserVisible by remember { mutableStateOf(true) }
var keepBrowserSession by remember { mutableStateOf(true) }
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) }
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my-workspace")) }
var receiptWorkspaceSlug by remember { mutableStateOf(prefs.get(PREF_RECEIPT_WORKSPACE_SLUG, "receipts")) }
var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "Llama-3.1-8B-Vision")) }
var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
var imagesForSelection by remember { mutableStateOf<List<String>>(emptyList()) }
var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) }
var manualKeyword by remember { mutableStateOf("") }
var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다.") }
var isImageUploadDialogVisible by remember { mutableStateOf(false) }
var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") }
var isReceiptDialogVisible by remember { mutableStateOf(false) }
var analysisJob by remember { mutableStateOf<Job?>(null) }
var receiptContextPrompt by remember { mutableStateOf("") }
var userScrapComment by remember { mutableStateOf("") }
var userMainTopic by remember { mutableStateOf("") }
var revisionRequest by remember { mutableStateOf("") }
LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages); prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) }
LaunchedEffect(apiKey) { prefs.put(PREF_API_KEY, apiKey) }
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) }
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
if (isImageUploadDialogVisible) { FileDialog { selected -> isImageUploadDialogVisible = false; if (selected.isNotEmpty()) { uploadedImageFiles = uploadedImageFiles + selected; logMessage(logMessages, "✅ 개인 이미지 파일 ${selected.size}개 추가됨.") } } }
if (isReceiptDialogVisible) { FileDialog { selected -> isReceiptDialogVisible = false; if (selected.isNotEmpty()) { receiptFiles = receiptFiles + selected; logMessage(logMessages, "🧾 영수증 이미지 ${selected.size}개 추가됨.") } } }
MaterialTheme {
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it }); Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f)); Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it }); Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f)) }
Spacer(Modifier.height(8.dp)); OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true); Spacer(Modifier.height(4.dp)); OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation()); Spacer(Modifier.height(4.dp))
Row(Modifier.fillMaxWidth()) { OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("블로그용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true); Spacer(Modifier.width(4.dp)); OutlinedTextField(value = receiptWorkspaceSlug, onValueChange = { receiptWorkspaceSlug = it }, label = { Text("영수증 처리용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true) }
Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("사용 중인 LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
}
Divider()
TabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } }
when (tabIndex) {
0 -> ScrapBasedPostTab(
isLoading, keywords, searchResults, scrapedFiles, selectedFiles, viewedFileContent, imagesForSelection,
currentSelectedImages, userPrompt, imageLoader, manualKeyword, userScrapComment, userMainTopic,
onManualKeywordChange = { manualKeyword = it }, onUserPromptChange = { userPrompt = it }, onUserScrapCommentChange = { userScrapComment = it }, onUserMainTopicChange = { userMainTopic = it },
onFetchTrends = { coroutineScope.launch(Dispatchers.IO) { isLoading = true; keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
onKeywordSelect = { keyword -> selectedKeyword = keyword; manualKeyword = keyword; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
onSearchResultSelect = { result -> coroutineScope.launch(Dispatchers.IO) { isLoading = true; scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)?.let { data -> val finalKeyword = if (selectedKeyword.isNotBlank()) selectedKeyword else manualKeyword; val savedFile = saveDataToJsonFile(finalKeyword, data, scrapedFolderPath,logMessages); scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages); currentlyOpenFile = savedFile; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() }; isLoading = false } },
onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages) } },
onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file },
onFileView = { file -> coroutineScope.launch(Dispatchers.IO) { try { val data = jsonParser.decodeFromString<ScrapedData>(file.readText()); currentlyOpenFile = file; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") } } },
onImageSelect = { imageUrl -> currentSelectedImages = if (imageUrl in currentSelectedImages) currentSelectedImages - imageUrl else currentSelectedImages + imageUrl },
onSaveChanges = { coroutineScope.launch(Dispatchers.IO) { currentlyOpenFile?.let { file -> try { val originalData = jsonParser.decodeFromString<ScrapedData>(file.readText()); val updatedData = originalData.copy(selectedImageUrls = currentSelectedImages.toList()); file.writeText(jsonParser.encodeToString(updatedData)); logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.") } catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") } } } },
onGeneratePost = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@ScrapBasedPostTab }
if (selectedFiles.isEmpty()) { logMessage(logMessages, "⚠️ 글을 생성할 스크랩 파일을 1개 이상 선택해주세요."); return@ScrapBasedPostTab }
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
try {
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
val allImages = selectedDataList.flatMap { it.selectedImageUrls }.distinct()
val uploadedDocIds = uploadFiles(selectedFiles.toList(), apiKey, workspaceSlug,logMessages)
if (uploadedDocIds.isEmpty()) { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다."); return@launch }
val resultText = generateBlogPost(selectedDataList, "", allImages, userPrompt, userScrapComment, userMainTopic, apiKey, workspaceSlug,logMessages)
// ⭐️ [수정] 컨텍스트를 포함한 꼬리말 생성
val footer = buildString {
appendLine("\n\n---")
if (selectedDataList.isNotEmpty()) {
appendLine("- 원문 출처:")
selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") }
}
if (allImages.isNotEmpty()) {
appendLine("\n- 사용된 이미지:")
allImages.forEach { appendLine(" - $it") }
}
appendLine("\n\n[이 글의 작성 과정]")
val contextSummary = mutableListOf<String>()
if (userMainTopic.isNotBlank()) contextSummary.add("주제: '${userMainTopic}'")
if (userScrapComment.isNotBlank()) contextSummary.add("작성자 코멘트: '${userScrapComment.take(30)}...'")
if (userPrompt.isNotBlank()) contextSummary.add("요청 스타일: '${userPrompt.take(30)}...'")
append("이 포스팅은 ")
if (contextSummary.isNotEmpty()) append("${contextSummary.joinToString(", ")} 등의 정보를 바탕으로, ")
append("여러 참고 자료를 종합하여 ${modelName} AI 모델이 초안을 생성했습니다. 이후 작성자의 검토를 거쳐 수정 및 발행되었습니다.")
}
blogPostResult = resultText
blogPostFooter = footer
tabIndex = 4
cleanupWorkspace(uploadedDocIds, apiKey,logMessages)
} finally { isLoading = false }
}
}
)
1 -> DirectPostTab(
isLoading = isLoading,
userOwnContent = userOwnContent,
onUserOwnContentChange = { userOwnContent = it },
uploadedImageFiles = uploadedImageFiles,
imageLoader = imageLoader,
userPrompt = userPrompt,
onUserPromptChange = { userPrompt = it },
onUploadImage = { isImageUploadDialogVisible = true },
onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove },
onGeneratePost = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@DirectPostTab }
if (userOwnContent.isBlank() || uploadedImageFiles.isEmpty()) { logMessage(logMessages, "⚠️ 직접 포스팅을 하려면 내용과 이미지가 모두 필요합니다."); return@DirectPostTab }
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
try {
val allImages = uploadedImageFiles.map { it.toURI().toString() }
val resultText = generateBlogPost(emptyList(), userOwnContent, allImages, userPrompt, "", "", apiKey, workspaceSlug,logMessages)
// ⭐️ [수정] 컨텍스트를 포함한 꼬리말 생성 (직접 포스팅용)
val footer = buildString {
appendLine("\n\n---")
if (allImages.isNotEmpty()) {
appendLine("- 사용된 이미지:")
allImages.forEach { appendLine(" - $it") }
}
appendLine("\n\n[이 글의 작성 과정]")
append("이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ")
if (userPrompt.isNotBlank()) append("'${userPrompt.take(30)}...' 스타일로 문체와 구성을 다듬도록 요청하여, ")
append("${modelName} AI 모델의 도움을 받아 완성되었습니다.")
}
blogPostResult = resultText
blogPostFooter = footer
tabIndex = 4
} finally { isLoading = false }
}
}
)
2 -> ReceiptAnalyzerTab(
isLoading, receiptFiles, receiptAnalysisResult, isAnalyzing = analysisJob?.isActive == true,
receiptContextPrompt, imageLoader,
onUploadReceipt = { isReceiptDialogVisible = true },
onRemoveReceipt = { file -> receiptFiles = receiptFiles - file },
onReceiptContextPromptChange = { receiptContextPrompt = it },
onAnalyzeReceipts = {
analysisJob = coroutineScope.launch(Dispatchers.IO) {
try {
isLoading = true
val resultFlow = MutableStateFlow("")
val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } }
analyzeReceipts(receiptFiles, apiKey, receiptWorkspaceSlug, receiptContextPrompt,logMessages, resultFlow)
uiUpdateJob.cancel()
} catch (e: CancellationException) { logMessage(logMessages, " 영수증 분석이 사용자에 의해 중단되었습니다."); receiptAnalysisResult = "분석이 중단되었습니다."
} catch (e: Exception) { logMessage(logMessages, "❌ 영수증 분석 중 심각한 오류 발생: ${e.message}"); receiptAnalysisResult = "오류 발생: ${e.message}"
} finally { isLoading = false; analysisJob = null }
}
},
onCancelAnalysis = { analysisJob?.cancel() }
)
3 -> LogTab(logMessages)
4 -> ResultTab(
result = blogPostResult,
onRequestResultChange = { blogPostResult = it },
revisionRequest = revisionRequest,
onRevisionRequestChange = { revisionRequest = it },
isLoading = isLoading,
onRevise = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) {
logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요.")
return@ResultTab
}
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
try {
val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug,logMessages)
blogPostResult = revisedText
revisionRequest = ""
} finally {
isLoading = false
}
}
},
onCopyToClipboard = {
val fullContent = blogPostResult + blogPostFooter
val stringSelection = StringSelection(fullContent)
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(stringSelection, null)
logMessage(logMessages, "✅ 블로그 전체 내용(꼬리말 포함)이 클립보드에 복사되었습니다.")
}
)
}
}
}
}

319
src/main/kotlin/ui/tabs.kt Normal file
View File

@ -0,0 +1,319 @@
// ui/tabs/ScrapBasedPostTab.kt
package ui.tabs
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
// (필요한 import 추가)
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.ImageLoader
import coil3.compose.rememberAsyncImagePainter
import io.ktor.client.request.url
import models.SearchResult
import java.io.File
@Composable
fun ScrapBasedPostTab(
isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>, scrapedFiles: List<File>, selectedFiles: Set<File>,
viewedFileContent: String, imagesForSelection: List<String>, currentSelectedImages: Set<String>,
userPrompt: String, imageLoader: ImageLoader, manualKeyword: String, userScrapComment: String, userMainTopic: String,
onManualKeywordChange: (String) -> Unit, onUserPromptChange: (String) -> Unit, onUserScrapCommentChange: (String) -> Unit, onUserMainTopicChange: (String) -> Unit,
onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit,
onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit, onFileView: (File) -> Unit,
onImageSelect: (String) -> Unit, onSaveChanges: () -> Unit, onGeneratePost: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
// 1. 키워드 및 검색
Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) {
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text("키워드 직접 입력") }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading)
Spacer(Modifier.width(4.dp))
Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text("검색") }
}
Spacer(Modifier.height(8.dp))
Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") }
Divider(modifier = Modifier.padding(vertical = 8.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) }
}
}
// 2. 검색 결과 및 스크랩
Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(4.dp)) {
Text("검색 결과 (클릭하여 스크랩)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(searchResults) { result ->
Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) {
Text(result.title, style = MaterialTheme.typography.subtitle1, color = MaterialTheme.colors.primary)
Text(result.url, style = MaterialTheme.typography.caption, maxLines = 1)
}
}
}
}
// 3. 파일 관리 및 생성
Column(modifier = Modifier.weight(3f).padding(horizontal = 4.dp).verticalScroll(rememberScrollState())) {
// 저장된 파일
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("저장된 파일", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") }
}
Box(modifier = Modifier.heightIn(max = 200.dp)) {
LazyColumn {
items(scrapedFiles) { file ->
Row(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = file in selectedFiles, onCheckedChange = { isChecked -> onFileSelectToggle(file, isChecked) }, enabled = !isLoading)
Text(file.name, modifier = Modifier.padding(start = 4.dp), maxLines = 1)
}
}
}
}
Spacer(Modifier.height(8.dp))
Text("파일 내용", style = MaterialTheme.typography.subtitle1)
Text(text = viewedFileContent, modifier = Modifier.height(100.dp).fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp).verticalScroll(rememberScrollState()), style = MaterialTheme.typography.body2)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("대표 이미지 선택 (다중 가능)", style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f))
Button(onClick = onSaveChanges, enabled = !isLoading && imagesForSelection.isNotEmpty()) { Text("선택 이미지 저장") }
}
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) {
items(imagesForSelection) { imageUrl ->
val isSelected = imageUrl in currentSelectedImages
Box(modifier = Modifier.padding(4.dp)) {
Image(painter = rememberAsyncImagePainter(model = imageUrl, imageLoader = imageLoader), contentDescription = "Scraped Image", modifier = Modifier.size(100.dp).clickable { onImageSelect(imageUrl) }.border(if (isSelected) 4.dp else 0.dp, MaterialTheme.colors.primary), contentScale = ContentScale.Crop)
}
}
}
}
Spacer(Modifier.height(16.dp))
// 글 생성 제어
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(8.dp)) {
Text("LLM 요청사항", style = MaterialTheme.typography.h6)
OutlinedTextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") })
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = userMainTopic,
onValueChange = onUserMainTopicChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("글의 핵심 주제 (예: 2025년 최신 IT 트렌드)") },
singleLine = true
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(value = userScrapComment, onValueChange = onUserScrapCommentChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text("작성자 코멘트 (스크랩한 내용에 대한 당신의 생각)") })
Spacer(Modifier.height(8.dp))
Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
Text("선택한 파일(${selectedFiles.size}개)로 글 생성")
}
}
}
}
if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
}
}
@Composable
fun DirectPostTab(
isLoading: Boolean,
userOwnContent: String,
onUserOwnContentChange: (String) -> Unit,
uploadedImageFiles: List<File>,
imageLoader: ImageLoader,
userPrompt: String,
onUserPromptChange: (String) -> Unit,
onUploadImage: () -> Unit,
onRemoveUploadedImage: (File) -> Unit,
onGeneratePost: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize().padding(8.dp)) {
// 1. 내용 작성
Column(modifier = Modifier.weight(2f).padding(end = 8.dp)) {
Text("직접 작성 (여행 기록, 정보 공유 등)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
OutlinedTextField(
value = userOwnContent,
onValueChange = onUserOwnContentChange,
modifier = Modifier.fillMaxSize(),
label = { Text("블로그에 올릴 내용을 직접 작성하세요.") }
)
}
// 2. 이미지 및 생성 제어
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(8.dp)) {
Text("이미지 업로드", style = MaterialTheme.typography.h6)
Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("내 PC에서 이미지 업로드") }
Spacer(Modifier.height(8.dp))
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) {
items(uploadedImageFiles) { file ->
Box(modifier = Modifier.padding(4.dp)) {
Image(
painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader),
contentDescription = "Uploaded Image",
modifier = Modifier.size(100.dp).clickable { onRemoveUploadedImage(file) },
contentScale = ContentScale.Crop
)
}
}
}
Spacer(Modifier.height(16.dp))
Text("LLM 요청사항", style = MaterialTheme.typography.h6)
OutlinedTextField(
value = userPrompt,
onValueChange = onUserPromptChange,
modifier = Modifier.fillMaxWidth().height(150.dp),
label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") }
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onGeneratePost,
enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(),
modifier = Modifier.fillMaxWidth()
) { Text("작성한 내용으로 글 생성") }
}
}
if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
}
}
@Composable
fun ReceiptAnalyzerTab(
isLoading: Boolean,
receiptFiles: List<File>,
receiptAnalysisResult: String,
isAnalyzing: Boolean,
receiptContextPrompt: String,
imageLoader: ImageLoader,
onUploadReceipt: () -> Unit,
onRemoveReceipt: (File) -> Unit,
onReceiptContextPromptChange: (String) -> Unit,
onAnalyzeReceipts: () -> Unit,
onCancelAnalysis: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("영수증 분석기", style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 8.dp))
Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("영수증 이미지 업로드") }
Spacer(Modifier.height(8.dp))
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) {
items(receiptFiles) { file ->
Box(modifier = Modifier.padding(4.dp)) {
Image(
painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader),
contentDescription = "Receipt Image",
modifier = Modifier.size(100.dp).clickable { onRemoveReceipt(file) },
contentScale = ContentScale.Crop
)
}
}
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = receiptContextPrompt,
onValueChange = onReceiptContextPromptChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("추가 정보를 입력하면 더 정확하게 분석할 수 있습니다.") },
label = { Text("추가 정보 입력 (예: 부산 출장 경비)") }
)
Spacer(Modifier.height(16.dp))
if (isAnalyzing) {
Button(onClick = onCancelAnalysis, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text("분석 중단하기")
}
}
} else {
Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
Text("선택한 영수증 분석 시작 (${receiptFiles.size}개)")
}
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = receiptAnalysisResult,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxSize(),
label = { Text("분석 결과 (내용 복사하여 사용)") }
)
}
if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
}
}
@Composable
fun LogTab(logs: List<String>) {
TextField(value = logs.joinToString("\n"), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize().padding(8.dp))
}
@Composable
fun ResultTab(
result: String,
onRequestResultChange: (String) -> Unit,
revisionRequest: String,
onRevisionRequestChange: (String) -> Unit,
isLoading: Boolean,
onRevise: () -> Unit,
onCopyToClipboard: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = result,
onValueChange = onRequestResultChange,
modifier = Modifier.weight(1f).fillMaxWidth(),
label = { Text("블로그 글 결과 (LLM 생성 본문)") }
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = revisionRequest,
onValueChange = onRevisionRequestChange,
modifier = Modifier.fillMaxWidth().height(100.dp),
label = { Text("추가 요청사항") },
placeholder = { Text("예: 문체를 좀 더 전문적으로 바꿔줘. 1번 항목을 더 자세히 설명해줘.") },
enabled = !isLoading
)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onRevise,
enabled = !isLoading && revisionRequest.isNotBlank(),
modifier = Modifier.weight(1f)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp)
} else {
Text("LLM으로 글 보완하기")
}
}
Spacer(Modifier.width(8.dp))
Button(
onClick = onCopyToClipboard,
enabled = !isLoading && result.isNotBlank(),
modifier = Modifier.weight(1f)
) {
Text("전체 내용 클립보드에 복사")
}
}
}
}
}

View File

@ -0,0 +1,22 @@
// ui/widgets/FileDialog.kt
package ui.widgets
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@Composable
fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List<File>) -> Unit) {
val fileDialog = remember {
FileDialog(parent, "파일 선택", FileDialog.LOAD).apply {
isMultipleMode = true
}
}
LaunchedEffect(Unit) {
fileDialog.isVisible = true
onCloseRequest(fileDialog.files.toList())
}
}

View File

@ -0,0 +1,33 @@
// utils/Global.kt
package utils
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import java.text.SimpleDateFormat
import java.util.*
import java.util.prefs.Preferences
object Global {
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) }
install(HttpTimeout) { requestTimeoutMillis = 900000 }
}
val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = true }
val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
const val PREF_FOLDER_PATH = "folder_path"
const val PREF_API_KEY = "api_key"
const val PREF_WORKSPACE_SLUG = "workspace_slug"
const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
const val PREF_MODEL_NAME = "model_name"
}
fun logMessage(logs: MutableList<String>, message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
logs.add(0, "$timestamp: $message")
}