diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 9316861..ef62109 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -31,14 +31,16 @@ import io.ktor.client.request.forms.formData import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.readUTF8Line +import io.ktor.utils.io.* 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 @@ -50,53 +52,47 @@ import java.io.File import java.text.SimpleDateFormat import java.util.* import java.util.prefs.Preferences +import kotlin.coroutines.cancellation.CancellationException -// --- 데이터 클래스 정의 --- -@Serializable data class AnythingLLMChatRequest(val message: String) -@Serializable data class AnythingLLMChatResponse(val textResponse: String?) +// --- 데이터 클래스 정의 (Sealed Interface 사용) --- -// [⭐️ 수정] AnythingLLM Vision API의 정확한 규격에 맞춘 데이터 클래스 @Serializable -data class AnythingLLMChatRequestWithVision( - val message: String, - val mode: String = "chat", - val sessionId: String? = null, - val attachments: List = emptyList(), - val reset: Boolean = false -) { +@JsonClassDiscriminator("type") +sealed interface SealedLLMStreamResponse { @Serializable - data class Attachment( - val name: String, - val mime: String, - val contentString: String - ) + @SerialName("textResponseChunk") + data class TextResponseChunk(val uuid: String, val textResponse: String?, val sources: List = 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) { @Serializable data class Document(val id: String, val location: String) } @Serializable data class DeleteDocumentsRequest(val deletes: List) data class SearchResult(val title: String, val url: String) @Serializable -data class ScrapedData( - val sourceUrl: String, - val selectedImageUrls: List, - val allImageUrls: List, - val content: String -) +data class ScrapedData(val sourceUrl: String, val selectedImageUrls: List, val allImageUrls: List, val content: String) // --- 전역 변수 및 헬퍼 --- private val httpClient = HttpClient(CIO) { install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) } - install(HttpTimeout) { requestTimeoutMillis = 1800000 } + install(HttpTimeout) { requestTimeoutMillis = 900000 } } -private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true; - coerceInputValues = true } +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" -// [⭐️ 추가] 영수증 처리용 워크스페이스 슬러그를 위한 새로운 Preference 키 private const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" private const val PREF_MODEL_NAME = "model_name" @@ -111,13 +107,9 @@ private fun getChromeDriver(options: ChromeOptions): ChromeDriver { if (driver == null) { driver = ChromeDriver(options) } return driver!! } +private fun quitChromeDriver() { driver?.quit(); driver = null } -private fun quitChromeDriver() { - driver?.quit() - driver = null -} - -// --- 핵심 기능 함수들 --- +// --- 핵심 기능 함수들 (스크랩, 업로드 등) --- suspend fun fetchGoogleTrends(logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): List { logMessage(logs, "Google Trends 페이지 스크랩 시작...") val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR" @@ -126,15 +118,10 @@ suspend fun fetchGoogleTrends(logs: MutableList, isBrowserVisible: Boole 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}개 스크랩 완료.") } + 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() - } + logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}"); quitChromeDriver(); emptyList() + } finally { if (!keepSession) quitChromeDriver() } } suspend fun searchOnGoogle(keyword: String, logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): List { @@ -154,11 +141,8 @@ suspend fun searchOnGoogle(keyword: String, logs: MutableList, isBrowser } logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.") } catch (e: Exception) { - logMessage(logs, "❌ Google 검색 중 오류: ${e.message}") - quitChromeDriver() - } finally { - if (!keepSession) quitChromeDriver() - } + logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); quitChromeDriver() + } finally { if (!keepSession) quitChromeDriver() } return results } @@ -171,36 +155,18 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList, isBrowser 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 - ) - } + 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() - } + logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}"); quitChromeDriver(); null + } finally { if (!keepSession) quitChromeDriver() } } suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKey: String, workspaceSlug: String): List { logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...") val uploadedDocIds = mutableListOf() - for (file in files) { + files.forEach { file -> logMessage(logs, " - '${file.name}' 업로드 중...") try { val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/upload") { @@ -208,18 +174,12 @@ suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKe setBody(MultiPartFormDataContent(formData { append("addToWorkspaces", workspaceSlug) val scrapedData = jsonParser.decodeFromString(file.readText()) - append("file", scrapedData.content.toByteArray(), Headers.build { - append(HttpHeaders.ContentType, ContentType.Text.Plain) - append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") - }) + 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.documents.firstOrNull()?.id?.let { - uploadedDocIds.add(it) - logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).") - } ?: logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.") + 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}") } } @@ -227,69 +187,20 @@ suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKe return uploadedDocIds } -suspend fun generateBlogPostWithLocalLLM( - scrapedDataList: List, - userOwnContent: String, - allSelectedImages: List, - userDirection: String, - logs: MutableList, - apiKey: String, - workspaceSlug: String -): String { +suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List, userOwnContent: String, allSelectedImages: List, userDirection: String, logs: MutableList, apiKey: String, workspaceSlug: String): String { logMessage(logs, "LLM 블로그 글 생성 요청...") - - val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) { - val referencesText = scrapedDataList.mapIndexed { index, data -> - "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" - }.joinToString("\n") - """ - --- 참고자료 --- - $referencesText - """.trimIndent() - } else { - """ - --- 주요 내용 --- - 아래 내용을 바탕으로 글을 작성해주세요. - $userOwnContent - """.trimIndent() - } - - val imagePromptPart = if (allSelectedImages.isNotEmpty()) { - val imageMarkdown = allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n") - "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n$imageMarkdown" - } else "" - - val finalPrompt = """ - 당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. - 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. - - $contentSourcePromptPart - - --- 요청사항 --- - 1. 사용자 요청: "$userDirection" - 2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요. - 3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요. - $imagePromptPart - 5. 글 마지막에 출처에 대한 언급은 절대 하지 마세요. - """.trimIndent() - - // 텍스트 기반 생성은 RAG가 가능한 chat API를, 이미지(영수증) 분석은 Vision이 가능한 chat API를 사용합니다. - // 여기서는 텍스트 기반 글 생성이므로 기존 `AnythingLLMChatRequest`를 사용합니다. - val requestBody = AnythingLLMChatRequest(message = finalPrompt) + 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(requestBody) + header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt)) } - val responseBodyText = response.bodyAsText() - logMessage(logs, "LLM 응답 수신...") - val chatResponse = jsonParser.decodeFromString(responseBodyText) + val chatResponse = jsonParser.decodeFromString(response.bodyAsText()) logMessage(logs, "✅ LLM 블로그 글 생성 완료.") - return chatResponse.textResponse ?: "" + return chatResponse.textResponse } catch (e: Exception) { - logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}") - return "블로그 글 생성 실패: ${e.message}" + logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}"); return "블로그 글 생성 실패: ${e.message}" } } @@ -298,176 +209,82 @@ suspend fun cleanupLLMWorkspace(docIds: List, logs: MutableList, 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)) + 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, val mode: String, val sessionId: String) -@Serializable -data class Message(val role: String, val content: String) - -@Serializable -data class Attachment(val name: String, val mime: String, val contentString: String) - -@Serializable -data class StreamChatRequest( - val message: String, - val attachments: List, - val mode: String, - val sessionId: String -) - -suspend fun analyzeReceiptsWithStreamChat( - files: List, - apiKey: String, - receiptWorkspaceSlug: String, - logs: MutableList, - // UI와 직접 연결된 StateFlow를 받아 실시간 업데이트 - resultState: MutableStateFlow -) { - if (apiKey.isBlank()) { - logs.add("AnythingLLM API Key is missing.") - resultState.value = "API Key missing" - return +suspend fun analyzeReceiptsWithStreamChat(files: List, apiKey: String, receiptWorkspaceSlug: String, logs: MutableList, resultState: MutableStateFlow, 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 } - if (receiptWorkspaceSlug.isBlank()) { - logs.add("Receipt workspace slug is missing.") - resultState.value = "Workspace Slug missing" - return - } - if (files.isEmpty()) { - logs.add("No receipt files provided.") - resultState.value = "No receipt files provided." - return - } - logs.add("Starting receipt analysis with stream-chat mode...") - resultState.value = "영수증 분석 중..." // 분석 시작 알림 - + logMessage(logs, "Starting receipt analysis with stream-chat mode...") + resultState.value = "영수증 분석 중..." try { - val messages = "첨부된 영수증 이미지들을 분석해서, 각 영수증별로 지출 내역을 정리해줘. 날짜, 항목, 금액이 잘 드러나게 마크다운 형식으로 깔끔하게 요약해줘." - 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 requestBody = StreamChatRequest( - message = messages, - attachments = attachments, - mode = "chat", - sessionId = "receipt-analysis-${System.currentTimeMillis()}" - ) - - // preparePost를 사용하여 스트리밍 응답을 더 세밀하게 제어 + 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) // 또는 "text/event-stream" - header("Authorization", "Bearer $apiKey") - contentType(ContentType.Application.Json) - setBody(requestBody) + 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) { - // 한 줄씩 읽어옵니다. SSE는 보통 줄 단위로 데이터가 옵니다. val line = channel.readUTF8Line() ?: continue - println(line) - // SSE 형식 (data: { ... })에서 실제 JSON 부분만 추출 if (line.startsWith("data:")) { val jsonChunk = line.removePrefix("data:").trim() - - // 스트리밍 데이터 조각을 파싱 (라이브러리 응답 형식에 맞춰야 함) - // 예: {"textResponse": "결과"} 와 같은 조각이 온다고 가정 + if (jsonChunk.isEmpty() || jsonChunk == "[DONE]") continue try { - val chunkResponse = jsonParser.decodeFromString(jsonChunk) - fullResponseText += chunkResponse.textResponse - - // 💥 UI 상태를 실시간으로 업데이트! - resultState.value = fullResponseText - } catch (e: Exception) { - // 파싱 오류는 무시하거나 로그를 남길 수 있습니다. - e.printStackTrace() - } - } else { - + when (val streamObject = jsonParser.decodeFromString(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") } } } - logs.add("Receipt analysis stream completed.") - } else { - val errorBody = response.bodyAsText() - logs.add("Error during receipt analysis: ${response.status} - $errorBody") - resultState.value = "API 오류: ${response.status}" - } + 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: Exception) { - logs.add("Exception in receipt analysis: ${e.message}") - resultState.value = e.message ?: "알 수 없는 오류" - } + } 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, 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) - try { - file.writeText(jsonParser.encodeToString(data)) - logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.") - } catch (e: Exception) { - logMessage(logs, "❌ 파일 저장 중 오류: ${e.message}") - } + file.writeText(jsonParser.encodeToString(data)) + logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.") return file } fun loadScrapedJsonFiles(logs: MutableList, folderPath: String): List { logMessage(logs, "'$folderPath'에서 파일 목록 로딩...") - return try { - File(folderPath).listFiles { _, name -> name.endsWith(".json") } - ?.sortedByDescending { it.lastModified() } - .orEmpty().also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") } - } catch (e: Exception) { - logMessage(logs, "❌ 파일 로딩 중 오류: ${e.message}") - emptyList() - } + 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) -> Unit -) { - val fileDialog = remember { - FileDialog(parent, "파일 선택", FileDialog.LOAD).apply { - isMultipleMode = true - } - } - - LaunchedEffect(Unit) { - fileDialog.isVisible = true - onCloseRequest(fileDialog.files.toList()) - } +fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List) -> 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>(emptyList()) } var searchResults by remember { mutableStateOf>(emptyList()) } var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") } @@ -475,97 +292,48 @@ fun App(imageLoader: ImageLoader) { 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")) } - // [⭐️ 추가] 영수증 처리용 슬러그 상태 변수 추가, 기본값 "receipts" 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>(emptyList()) } var selectedFiles by remember { mutableStateOf>(emptySet()) } - var currentlyOpenFile by remember { mutableStateOf(null) } var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") } var imagesForSelection by remember { mutableStateOf>(emptyList()) } var currentSelectedImages by remember { mutableStateOf>(emptySet()) } - var manualKeyword by remember { mutableStateOf("") } - var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다. 경포 해변의 일출이 정말 아름다웠고, 근처 카페에서 마신 커피도 훌륭했습니다.") } + var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다.") } var isImageUploadDialogVisible by remember { mutableStateOf(false) } var uploadedImageFiles by remember { mutableStateOf>(emptyList()) } - var receiptFiles by remember { mutableStateOf>(emptyList()) } - var isReceiptDialogVisible by remember { mutableStateOf(false) } - var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") } - val receiptAnalysisResultFlow = remember { MutableStateFlow(receiptAnalysisResult) } + var isReceiptDialogVisible by remember { mutableStateOf(false) } + var analysisJob by remember { mutableStateOf(null) } + var receiptContextPrompt by remember { mutableStateOf("") } - LaunchedEffect(Unit) { - receiptAnalysisResultFlow.collect { newResult -> - receiptAnalysisResult = newResult - } - } - LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) } - LaunchedEffect(scrapedFolderPath) { prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) } + 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) } - // [⭐️ 추가] 영수증 슬러그 값이 변경될 때마다 Preferences에 저장 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}개 추가됨.") - } - } - } - + 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)) - // [⭐️ 수정] API 키 필드를 한 줄 전체 사용하도록 변경 - OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation()) - Spacer(Modifier.height(4.dp)) - // [⭐️ 수정] Workspace Slug 입력 필드를 두 개로 나누어 배치 - 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) + 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 }) } - } + 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, @@ -575,135 +343,37 @@ fun App(imageLoader: ImageLoader) { 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 - } - }, + 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(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(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}") } - } - } - }, + onFileView = { file -> coroutineScope.launch(Dispatchers.IO) { try { val data = jsonParser.decodeFromString(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(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(it.readText()) } - val uploadedImageUrls = uploadedImageFiles.map { it.toURI().toString() } - - 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 { - uploadedImageUrls - } - - if (!isScrapBased && (userOwnContent.isBlank() || allImages.isEmpty())) { - logMessage(logMessages, "⚠️ 직접 포스팅을 하려면 '직접 작성' 내용과 '업로드한 이미지'가 모두 필요합니다.") - return@launch - } - - var uploadedDocIds: List = emptyList() - if (isScrapBased) { - uploadedDocIds = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug) - if (uploadedDocIds.isEmpty()) { - logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.") - return@launch - } - } - - 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 - } - } - }, + 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(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 = { - coroutineScope.launch(Dispatchers.IO) { - isLoading = true - // [⭐️ 수정] 영수증 분석 함수 호출 시, 'receiptWorkspaceSlug' 값을 전달 - analyzeReceiptsWithStreamChat(receiptFiles, apiKey, receiptWorkspaceSlug, logMessages, receiptAnalysisResultFlow) - isLoading = false + 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) @@ -728,155 +398,46 @@ fun WorkflowTab( receiptAnalysisResult: String, onUploadReceipt: () -> Unit, onRemoveReceipt: (File) -> Unit, - onAnalyzeReceipts: () -> 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)) - } - } + 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) - } - } - } + 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 - ) - } - } - } + 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 - ) - } - } - } + 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)) - 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}개 파일)" + 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}개)") } } - Text(buttonText) + 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) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } + if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } @@ -887,19 +448,16 @@ fun LogTab(logs: List) { @Composable fun ResultTab(result: String) { - Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { - Text(result) - } + Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { Text(result) } } fun main() = application { val imageLoader = ImageLoader.Builder(coil3.PlatformContext.INSTANCE) .components { add(OkHttpNetworkFetcherFactory()) } .build() - Window( onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() }, - title = "자동 블로그 포스팅 도우미 v7.1 (Receipt-Workspace-Separated)" + title = "자동 블로그 포스팅 도우미 v9.0 (Context-Aware)" ) { App(imageLoader) }