From 92aa1a998e402b17221ecbb860967d3eb559e89d Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Mon, 13 Oct 2025 16:18:55 +0900 Subject: [PATCH] ... --- src/main/kotlin/Main.kt | 58 +--- src/main/kotlin/core/BrowserManager.kt | 27 -- src/main/kotlin/core/FileManager.kt | 29 -- src/main/kotlin/core/LlmApiService.kt | 91 ++++--- src/main/kotlin/core/ScrapingService.kt | 55 +++- src/main/kotlin/ui/App.kt | 331 +++++++++++++++++------ src/main/kotlin/ui/tabs.kt | 243 ++++++++++++++--- src/main/kotlin/ui/widgets/FileDialog.kt | 3 +- src/main/kotlin/utils/Global.kt | 269 ++++++++++++++++++ 9 files changed, 818 insertions(+), 288 deletions(-) delete mode 100644 src/main/kotlin/core/BrowserManager.kt delete mode 100644 src/main/kotlin/core/FileManager.kt diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index e26af59..ccf84e3 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,62 +1,12 @@ -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 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.layout.ContentScale -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp +// Main.kt import androidx.compose.ui.window.Window 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.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.request.* -import io.ktor.client.request.forms.MultiPartFormDataContent -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.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -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.BrowserManager.quitChromeDriver 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 - - +import utils.Strings fun main() = application { @@ -65,7 +15,7 @@ fun main() = application { .build() Window( onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() }, - title = "자동 블로그 포스팅 도우미 v14.0 (Contextual Footer)" // ⭐️ [수정] 버전명 변경 + title = Strings.APP_TITLE ) { App(imageLoader) } diff --git a/src/main/kotlin/core/BrowserManager.kt b/src/main/kotlin/core/BrowserManager.kt deleted file mode 100644 index a748e5f..0000000 --- a/src/main/kotlin/core/BrowserManager.kt +++ /dev/null @@ -1,27 +0,0 @@ -// 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 - } -} \ No newline at end of file diff --git a/src/main/kotlin/core/FileManager.kt b/src/main/kotlin/core/FileManager.kt deleted file mode 100644 index 9a859f2..0000000 --- a/src/main/kotlin/core/FileManager.kt +++ /dev/null @@ -1,29 +0,0 @@ -// 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): 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): List { - logMessage(logs, "'$folderPath'에서 파일 목록 로딩...") - return File(folderPath).listFiles { _, name -> name.endsWith(".json") } - ?.sortedByDescending { it.lastModified() } - .orEmpty() - .also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") } - } -} \ No newline at end of file diff --git a/src/main/kotlin/core/LlmApiService.kt b/src/main/kotlin/core/LlmApiService.kt index 6ac8a90..9c8e366 100644 --- a/src/main/kotlin/core/LlmApiService.kt +++ b/src/main/kotlin/core/LlmApiService.kt @@ -12,18 +12,19 @@ import models.* import utils.Global import utils.Global.httpClient import utils.Global.jsonParser +import utils.Strings import utils.logMessage import java.io.File -import java.util.Base64 +import java.util.* import kotlin.coroutines.cancellation.CancellationException object LlmApiService { suspend fun uploadFiles(files: List, apiKey: String, workspaceSlug: String, logs: MutableList): List { - logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...") + logMessage(logs, Strings.logLlmUploadStarting(files.size)) val uploadedDocIds = mutableListOf() files.forEach { file -> - logMessage(logs, " - '${file.name}' 업로드 중...") + logMessage(logs, Strings.logLlmUploadingFile(file.name)) try { val response: HttpResponse = Global.httpClient.post("http://localhost:3001/api/v1/document/upload") { header("Authorization", "Bearer $apiKey") @@ -35,11 +36,13 @@ object LlmApiService { } 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}' 업로드 응답에 문서 정보가 없습니다.") - } else { logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}") } - } catch (e: Exception) { logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}") } + uploadResponse.documents.firstOrNull()?.id?.let { + uploadedDocIds.add(it); logMessage(logs, Strings.logLlmUploadSuccess(file.name, it)) + } ?: logMessage(logs, Strings.logLlmUploadResponseError(file.name, "", Strings.LOG_LLM_UPLOAD_NO_DOC_INFO)) + } else { logMessage(logs, Strings.logLlmUploadResponseError(file.name, response.status, response.bodyAsText())) } + } catch (e: Exception) { logMessage(logs, Strings.logLlmUploadException(file.name, e.message)) } } - logMessage(logs, "총 ${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.") + logMessage(logs, Strings.logLlmUploadFinished(files.size, uploadedDocIds.size)) return uploadedDocIds } @@ -52,9 +55,11 @@ object LlmApiService { userMainTopic: String, apiKey: String, workspaceSlug: String, + generatePromptPrefix: String, + generatePromptInstructions: List, // ⭐️ [추가] 요청사항 리스트 인자 logs: MutableList ): String { - logMessage(logs, "LLM 블로그 글 생성 요청...") + logMessage(logs, Strings.LOG_LLM_POST_GENERATION_START) 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" } @@ -72,29 +77,26 @@ object LlmApiService { 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() + // ⭐️ [수정] Strings.Prompts.generateBlogPost 호출 시 인자 추가 + val finalPrompt = Strings.Prompts.generateBlogPost( + basePrompt = generatePromptPrefix, + userDirection = userDirection, + instructions = generatePromptInstructions, + contentSourcePromptPart = contentSourcePromptPart, + commentPromptPart = commentPromptPart, + mainTopicPromptPart = mainTopicPromptPart, + imagePromptPart = imagePromptPart + ) 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(response.bodyAsText()) - logMessage(logs, "✅ LLM 블로그 글 생성 완료.") + logMessage(logs, Strings.LOG_LLM_POST_GENERATION_SUCCESS) return chatResponse.textResponse } catch (e: Exception) { - logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}"); return "블로그 글 생성 실패: ${e.message}" + logMessage(logs, Strings.logLlmApiError(e.message)); return "블로그 글 생성 실패: ${e.message}" } } @@ -103,16 +105,18 @@ object LlmApiService { revisionRequest: String, apiKey: String, workspaceSlug: String, + revisePromptPrefix: String, // ⭐️ [수정] 프롬프트 인자 추가 logs: MutableList ): String { - logMessage(logs, "LLM 블로그 글 수정 요청...") + logMessage(logs, Strings.LOG_LLM_REVISION_START) if (revisionRequest.isBlank()) { - logMessage(logs, "⚠️ 수정 요청사항이 비어있어 수정을 중단합니다.") + logMessage(logs, Strings.LOG_LLM_REVISION_EMPTY) return currentPost } + // ⭐️ [수정] 인자로 받은 프롬프트를 사용 val finalPrompt = """ - 당신은 주어진 글을 사용자의 요청에 맞게 수정하는 전문 편집자입니다. 아래의 원본 글을 수정 요청사항에 따라 개선해주세요. 원본의 주제와 주요 내용은 유지하되, 요청을 충실히 반영하여 더 나은 글로 만들어주세요. 마크다운 형식은 유지해주세요. + $revisePromptPrefix --- 원본 글 --- $currentPost @@ -128,23 +132,23 @@ object LlmApiService { header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt)) } val chatResponse = jsonParser.decodeFromString(response.bodyAsText()) - logMessage(logs, "✅ LLM 블로그 글 수정 완료.") + logMessage(logs, Strings.LOG_LLM_REVISION_SUCCESS) return chatResponse.textResponse } catch (e: Exception) { - logMessage(logs, "❌ LLM 수정 호출 중 오류: ${e.message}"); return "블로그 글 수정 실패: ${e.message}" + logMessage(logs, Strings.logLlmApiError(e.message)); return "블로그 글 수정 실패: ${e.message}" } } suspend fun cleanupWorkspace(docIds: List, apiKey: String, logs: MutableList) { if (docIds.isEmpty()) return - logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...") + logMessage(logs, Strings.logLlmCleanupStart(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}") } + if (response.status.isSuccess()) { logMessage(logs, Strings.LOG_LLM_CLEANUP_SUCCESS) } + else { logMessage(logs, Strings.logLlmCleanupError(response.status, response.bodyAsText())) } + } catch (e: Exception) { logMessage(logs, Strings.logLlmCleanupException(e.message)) } } suspend fun analyzeReceipts( @@ -152,18 +156,21 @@ object LlmApiService { apiKey: String, receiptWorkspaceSlug: String, receiptContext: String, + receiptPromptBase: String, // ⭐️ [수정] 프롬프트 인자 추가 logs: MutableList, resultState: MutableStateFlow ) { 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 + val missing = listOfNotNull(if (apiKey.isBlank()) "API Key" else null, if (receiptWorkspaceSlug.isBlank()) "영수증 Workspace" else null, if (files.isEmpty()) "영수증 파일" else null).joinToString() + logMessage(logs, Strings.logReceiptAnalysisMissing(missing)); return } - logMessage(logs, "Starting receipt analysis with stream-chat mode...") + logMessage(logs, Strings.LOG_RECEIPT_ANALYSIS_START) 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 + + // ⭐️ [수정] 인자로 받은 프롬프트를 사용 + val finalPrompt = if (receiptContext.isNotBlank()) Strings.Prompts.receiptAnalysisWithContext(receiptPromptBase, receiptContext) else receiptPromptBase 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") { @@ -180,16 +187,16 @@ object LlmApiService { try { 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 } + is SealedLLMStreamResponse.FinalizeResponseStream -> { logMessage(logs, Strings.LOG_RECEIPT_STREAM_FINALIZED); streamObject.metrics?.let { logs.add(Strings.logReceiptStreamMetrics(it.totalTokens, it.duration)) }; break } + is SealedLLMStreamResponse.AbortResponse -> { logMessage(logs, Strings.logReceiptStreamAborted(streamObject.textResponse)); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break } } - } catch (e: Exception) { logMessage(logs, "❌ Chunk parsing error: ${e.message} | Chunk: $jsonChunk") } + } catch (e: Exception) { logMessage(logs, Strings.logReceiptChunkParsingError(e.message, 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}" } + logMessage(logs, Strings.LOG_RECEIPT_STREAM_FINISHED) + } else { val errorBody = response.bodyAsText(); logMessage(logs, Strings.logReceiptApiError(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 ?: "알 수 없는 오류" } + catch (e: Exception) { logMessage(logs, Strings.logReceiptException(e.message)); resultState.value = e.message ?: "알 수 없는 오류" } } } \ No newline at end of file diff --git a/src/main/kotlin/core/ScrapingService.kt b/src/main/kotlin/core/ScrapingService.kt index be78656..c51ac4a 100644 --- a/src/main/kotlin/core/ScrapingService.kt +++ b/src/main/kotlin/core/ScrapingService.kt @@ -1,31 +1,34 @@ // core/ScrapingService.kt package core -import models.SearchResult import models.ScrapedData +import models.SearchResult import org.jsoup.Jsoup import org.openqa.selenium.By import org.openqa.selenium.chrome.ChromeOptions +import utils.BrowserManager +import utils.Strings import utils.logMessage object ScrapingService { suspend fun fetchGoogleTrends(logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): List { - logMessage(logs, "Google Trends 페이지 스크랩 시작...") + logMessage(logs, Strings.LOG_TRENDS_START) 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}개 스크랩 완료.") } + currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")).mapNotNull { it.text.takeIf(String::isNotBlank) } + .also { logMessage(logs, Strings.logTrendsSuccess(it.size)) } } catch (e: Exception) { - logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}"); BrowserManager.quitChromeDriver(); emptyList() + logMessage(logs, Strings.logTrendsError(e.message)); BrowserManager.quitChromeDriver(); emptyList() } finally { if (!keepSession) BrowserManager.quitChromeDriver() } } suspend fun searchOnGoogle(keyword: String, logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): List { - logMessage(logs, "'$keyword' 키워드로 Google 검색 시작...") + logMessage(logs, Strings.logSearchStart(keyword)) val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") } val currentDriver = BrowserManager.getChromeDriver(options) val results = mutableListOf() @@ -39,27 +42,49 @@ object ScrapingService { if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url)) } catch (e: Exception) { /* 개별 오류 무시 */ } } - logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.") + logMessage(logs, Strings.logSearchSuccess(keyword, results.size)) } catch (e: Exception) { - logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); BrowserManager.quitChromeDriver() + logMessage(logs, Strings.logSearchError(e.message)); BrowserManager.quitChromeDriver() } finally { if (!keepSession) BrowserManager.quitChromeDriver() } return results } - suspend fun scrapeArticleByUrl(url: String, logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? { - logMessage(logs, "URL 스크랩 시작: $url") + // ⭐️ [수정] 함수 시그니처에 selectors 파라미터 추가 + suspend fun scrapeArticleByUrl(url: String, selectors: List, logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? { + logMessage(logs, Strings.logScrapeUrlStart(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) } + + // ⭐️ [수정] 인자로 받은 selectors 리스트를 쉼표로 연결하여 Jsoup 쿼리로 사용하고, 첫 번째 요소를 찾습니다. + val articleElement = doc.select(selectors.joinToString(", "))?.firstOrNull() + + if (articleElement == null) { + logMessage(logs, Strings.LOG_WARN_ARTICLE_BODY_NOT_FOUND) + return null + } + + // 찾은 요소에서 텍스트와 이미지를 각각 추출합니다. + val articleContent = articleElement.text() + val allImages = articleElement.select("img") + .map { it.absUrl("src") } + .filter { it.isNotBlank() && it.startsWith("http") } + .distinct() + + if (articleContent.isBlank()) { + logMessage(logs, Strings.LOG_WARN_ARTICLE_BODY_NOT_FOUND) + null + } else { + logMessage(logs, Strings.logScrapeUrlSuccess(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() } + logMessage(logs, Strings.logScrapeUrlError(e.message)); BrowserManager.quitChromeDriver(); null + } finally { + if (!keepSession) BrowserManager.quitChromeDriver() + } } } \ No newline at end of file diff --git a/src/main/kotlin/ui/App.kt b/src/main/kotlin/ui/App.kt index 1580406..c68273c 100644 --- a/src/main/kotlin/ui/App.kt +++ b/src/main/kotlin/ui/App.kt @@ -12,10 +12,6 @@ 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 @@ -34,56 +30,67 @@ import models.ScrapedData import models.SearchResult import ui.tabs.* import ui.widgets.FileDialog +import utils.FileManager.loadScrapedJsonFiles +import utils.FileManager.saveDataToJsonFile +import utils.Global import utils.Global.jsonParser +import utils.Strings 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" +const val PREF_FOLDER_PATH = Global.PREF_FOLDER_PATH +const val PREF_API_KEY = Global.PREF_API_KEY +const val PREF_WORKSPACE_SLUG = Global.PREF_WORKSPACE_SLUG +const val PREF_RECEIPT_WORKSPACE_SLUG = Global.PREF_RECEIPT_WORKSPACE_SLUG +const val PREF_MODEL_NAME = Global.PREF_MODEL_NAME +const val PREF_PROMPT_GENERATE = Global.PREF_PROMPT_GENERATE +const val PREF_PROMPT_REVISE = Global.PREF_PROMPT_REVISE +const val PREF_PROMPT_RECEIPT = Global.PREF_PROMPT_RECEIPT +const val PREF_PROMPT_GENERATE_INSTRUCTIONS = Global.PREF_PROMPT_GENERATE_INSTRUCTIONS +// ⭐️ [추가] 셀렉터 설정 키 import +const val PREF_ARTICLE_SELECTORS = Global.PREF_ARTICLE_SELECTORS + -// --- 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으로부터 생성된 블로그 글이 여기에 표시됩니다.") } - var blogPostFooter by remember { mutableStateOf("") } + val tabs = Strings.TABS + "설정" val logMessages = remember { mutableStateListOf() } var isLoading by remember { mutableStateOf(false) } + + // --- 상태 변수 선언 --- + var blogPostResult by remember { mutableStateOf(Strings.PLACEHOLDER_BLOG_POST) } + var blogPostFooter by remember { mutableStateOf("") } + var generationContextForClipboard by remember { mutableStateOf("") } + var keywords by remember { mutableStateOf>(emptyList()) } + var searchResults by remember { mutableStateOf>(emptyList()) } var selectedKeyword by remember { mutableStateOf("") } - var userPrompt by remember { mutableStateOf("개인 기록용으로 가볍게 남기는 스타일로 작성해줘.") } + var userPrompt by remember { mutableStateOf(Strings.DEFAULT_USER_PROMPT) } 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 scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, Strings.DEFAULT_SCRAPED_FOLDER)) } 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 workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, Strings.DEFAULT_WORKSPACE_SLUG)) } + var receiptWorkspaceSlug by remember { mutableStateOf(prefs.get(PREF_RECEIPT_WORKSPACE_SLUG, Strings.DEFAULT_RECEIPT_WORKSPACE_SLUG)) } + var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, Strings.DEFAULT_MODEL_NAME)) } 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 viewedFileContent by remember { mutableStateOf(Strings.PLACEHOLDER_FILE_CONTENT) } + var combinedImagesFromSelectedFiles 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(Strings.DEFAULT_USER_OWN_CONTENT) } var isImageUploadDialogVisible by remember { mutableStateOf(false) } var uploadedImageFiles by remember { mutableStateOf>(emptyList()) } var receiptFiles by remember { mutableStateOf>(emptyList()) } - var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") } + var receiptAnalysisResult by remember { mutableStateOf(Strings.PLACEHOLDER_RECEIPT_ANALYSIS) } var isReceiptDialogVisible by remember { mutableStateOf(false) } var analysisJob by remember { mutableStateOf(null) } var receiptContextPrompt by remember { mutableStateOf("") } @@ -91,79 +98,186 @@ fun App(imageLoader: ImageLoader) { var userMainTopic by remember { mutableStateOf("") } var revisionRequest by remember { mutableStateOf("") } - LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages); prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) } + // --- 프롬프트 및 설정 상태 변수 --- + var generatePromptPrefix by remember { mutableStateOf(prefs.get(PREF_PROMPT_GENERATE, Strings.Prompts.GENERATE_POST_PREFIX)) } + var revisePromptPrefix by remember { mutableStateOf(prefs.get(PREF_PROMPT_REVISE, Strings.Prompts.REVISE_POST_PREFIX)) } + var receiptPromptBase by remember { mutableStateOf(prefs.get(PREF_PROMPT_RECEIPT, Strings.Prompts.RECEIPT_ANALYSIS_BASE)) } + var generatePromptInstructions by remember { + val saved = prefs.get(PREF_PROMPT_GENERATE_INSTRUCTIONS, null) + mutableStateOf( + saved?.split('\n')?.filter { it.isNotEmpty() } ?: Strings.Prompts.DEFAULT_GENERATE_POST_INSTRUCTIONS + ) + } + // ⭐️ [추가] 스크래핑 셀렉터 상태 변수 + var articleSelectors by remember { + val saved = prefs.get(PREF_ARTICLE_SELECTORS, null) + mutableStateOf( + saved?.split('\n')?.filter { it.isNotEmpty() } ?: Strings.DEFAULT_ARTICLE_SELECTORS + ) + } + + + // --- 설정 저장 로직 --- + 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) } + // ⭐️ [추가] 셀렉터 목록이 변경될 때마다 자동으로 Preferences에 저장 + LaunchedEffect(articleSelectors) { + prefs.put(PREF_ARTICLE_SELECTORS, articleSelectors.joinToString("\n")) + } - 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}개 추가됨.") } } } + + // --- selectedFiles 변경 감지 로직 --- + LaunchedEffect(selectedFiles) { + coroutineScope.launch(Dispatchers.IO) { + val allImages = mutableSetOf() + val allPreSelectedImages = mutableSetOf() + + selectedFiles.forEach { file -> + try { + val data = jsonParser.decodeFromString(file.readText()) + allImages.addAll(data.allImageUrls) + allPreSelectedImages.addAll(data.selectedImageUrls) + } catch (e: Exception) { + logMessage(logMessages, "⚠️ '${file.name}' 파일 파싱 오류: ${e.message}") + } + } + combinedImagesFromSelectedFiles = allImages.toList().sorted() + currentSelectedImages = allPreSelectedImages + } + } + + // --- 다이얼로그 관리 --- + if (isImageUploadDialogVisible) { FileDialog { selected -> isImageUploadDialogVisible = false; if (selected.isNotEmpty()) { uploadedImageFiles = uploadedImageFiles + selected; logMessage(logMessages, Strings.logImageFilesAdded(selected.size)) } } } + if (isReceiptDialogVisible) { FileDialog { selected -> isReceiptDialogVisible = false; if (selected.isNotEmpty()) { receiptFiles = receiptFiles + selected; logMessage(logMessages, Strings.logReceiptsAdded(selected.size)) } } } MaterialTheme { Column(modifier = Modifier.fillMaxSize()) { + // --- 상단 설정 UI --- 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) + Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it }); Text(Strings.LABEL_BROWSER_VISIBLE, modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f)); Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it }); Text(Strings.LABEL_BROWSER_SESSION, modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f)) } + Spacer(Modifier.height(8.dp)); OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text(Strings.LABEL_SCRAP_FOLDER_PATH) }, modifier = Modifier.fillMaxWidth(), singleLine = true) + Spacer(Modifier.height(4.dp)); OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text(Strings.PLACEHOLDER_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(Strings.LABEL_BLOG_WORKSPACE_SLUG) }, modifier = Modifier.weight(1f), singleLine = true); Spacer(Modifier.width(4.dp)); OutlinedTextField(value = receiptWorkspaceSlug, onValueChange = { receiptWorkspaceSlug = it }, label = { Text(Strings.LABEL_RECEIPT_WORKSPACE_SLUG) }, modifier = Modifier.weight(1f), singleLine = true) } + Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text(Strings.LABEL_MODEL_NAME) }, 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, + isLoading, keywords, searchResults, scrapedFiles, selectedFiles, viewedFileContent, + combinedImagesFromSelectedFiles, 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 } }, + // ⭐️ [수정] scrapeArticleByUrl 호출 시 articleSelectors 상태 변수 전달 + onSearchResultSelect = { result -> coroutineScope.launch(Dispatchers.IO) { isLoading = true; scrapeArticleByUrl(result.url, articleSelectors, logMessages, isBrowserVisible, keepBrowserSession)?.let { data -> val finalKeyword = manualKeyword.takeIf { it.isNotBlank() } ?: "scraped"; saveDataToJsonFile(finalKeyword, data, scrapedFolderPath,logMessages); scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages) }; 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(file.readText()); currentlyOpenFile = file; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() } 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 + } catch (e: Exception) { + logMessage(logMessages, Strings.logReadFileError(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}") } } } }, + onSaveChanges = { + coroutineScope.launch(Dispatchers.IO) { + if (selectedFiles.isEmpty()) { + logMessage(logMessages, Strings.LOG_WARN_NO_FILES_TO_SAVE) + return@launch + } + selectedFiles.forEach { file -> + try { + val originalData = jsonParser.decodeFromString(file.readText()) + val imagesToSaveForThisFile = currentSelectedImages.intersect(originalData.allImageUrls.toSet()) + val updatedData = originalData.copy(selectedImageUrls = imagesToSaveForThisFile.toList()) + file.writeText(jsonParser.encodeToString(updatedData)) + } catch (e: Exception) { + logMessage(logMessages, Strings.logSaveFileError(file.name, e.message)) + } + } + logMessage(logMessages, Strings.logImageSelectionSavedToFiles(selectedFiles.size)) + } + }, onGeneratePost = { - if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@ScrapBasedPostTab } - if (selectedFiles.isEmpty()) { logMessage(logMessages, "⚠️ 글을 생성할 스크랩 파일을 1개 이상 선택해주세요."); return@ScrapBasedPostTab } + if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@ScrapBasedPostTab } + if (selectedFiles.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_NO_FILES_SELECTED); return@ScrapBasedPostTab } coroutineScope.launch(Dispatchers.IO) { isLoading = true try { val selectedDataList = selectedFiles.map { jsonParser.decodeFromString(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 allImages = currentSelectedImages.toList() + val uploadedDocIds = uploadFiles(selectedFiles.toList(), apiKey, workspaceSlug, logMessages) + if (uploadedDocIds.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_UPLOAD_FAILED); return@launch } - val resultText = generateBlogPost(selectedDataList, "", allImages, userPrompt, userScrapComment, userMainTopic, apiKey, workspaceSlug,logMessages) + val resultText = generateBlogPost( + scrapedDataList = selectedDataList, + userOwnContent = "", + allSelectedImages = allImages, + userDirection = userPrompt, + userScrapComment = userScrapComment, + userMainTopic = userMainTopic, + apiKey = apiKey, + workspaceSlug = workspaceSlug, + generatePromptPrefix = generatePromptPrefix, + generatePromptInstructions = generatePromptInstructions, + logs = logMessages + ) - // ⭐️ [수정] 컨텍스트를 포함한 꼬리말 생성 val footer = buildString { - appendLine("\n\n---") + appendLine(Strings.Footer.SEPARATOR) if (selectedDataList.isNotEmpty()) { - appendLine("- 원문 출처:") + appendLine(Strings.Footer.ORIGINAL_SOURCE_TITLE) selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") } } if (allImages.isNotEmpty()) { - appendLine("\n- 사용된 이미지:") + appendLine(Strings.Footer.USED_IMAGES_TITLE) allImages.forEach { appendLine(" - $it") } } - - appendLine("\n\n[이 글의 작성 과정]") + appendLine(Strings.Footer.PROCESS_TITLE) val contextSummary = mutableListOf() - 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 모델이 초안을 생성했습니다. 이후 작성자의 검토를 거쳐 수정 및 발행되었습니다.") + if (userMainTopic.isNotBlank()) contextSummary.add(Strings.Footer.contextTopic(userMainTopic)) + if (userScrapComment.isNotBlank()) contextSummary.add(Strings.Footer.contextComment(userScrapComment)) + if (userPrompt.isNotBlank()) contextSummary.add(Strings.Footer.contextStyle(userPrompt)) + val contextText = if (contextSummary.isNotEmpty()) "${contextSummary.joinToString(", ")} " else "" + append(Strings.Footer.scrapBased(contextText, modelName)) } + val contextBuilder = StringBuilder() + contextBuilder.appendLine("\n\n---") + contextBuilder.appendLine("[Generation Context & Prompts]") + contextBuilder.appendLine("- Model: $modelName") + contextBuilder.appendLine("- Generation Type: Scrap-based Posting") + contextBuilder.appendLine("\n[Inputs]") + if (userMainTopic.isNotBlank()) contextBuilder.appendLine("- Main Topic: $userMainTopic") + if (userScrapComment.isNotBlank()) contextBuilder.appendLine("- User Comment: $userScrapComment") + contextBuilder.appendLine("- Style Request: $userPrompt") + contextBuilder.appendLine("- Source Files: ${selectedFiles.joinToString { it.name }}") + contextBuilder.appendLine("\n[Underlying Prompt]") + contextBuilder.appendLine("--- Prefix ---") + contextBuilder.appendLine(generatePromptPrefix) + contextBuilder.appendLine("\n--- Instructions ---") + generatePromptInstructions.forEach { contextBuilder.appendLine("- $it") } + generationContextForClipboard = contextBuilder.toString() + blogPostResult = resultText blogPostFooter = footer - tabIndex = 4 - cleanupWorkspace(uploadedDocIds, apiKey,logMessages) + tabIndex = tabs.indexOf("블로그 결과") + cleanupWorkspace(uploadedDocIds, apiKey, logMessages) } finally { isLoading = false } } } @@ -179,31 +293,58 @@ fun App(imageLoader: ImageLoader) { 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 } + if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@DirectPostTab } + if (userOwnContent.isBlank() && uploadedImageFiles.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_DIRECT_POST_EMPTY); 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 resultText = generateBlogPost( + scrapedDataList = emptyList(), + userOwnContent = userOwnContent, + allSelectedImages = allImages, + userDirection = userPrompt, + userScrapComment = "", + userMainTopic = "", + apiKey = apiKey, + workspaceSlug = workspaceSlug, + generatePromptPrefix = generatePromptPrefix, + generatePromptInstructions = generatePromptInstructions, + logs = logMessages + ) + val footer = buildString { - appendLine("\n\n---") + appendLine(Strings.Footer.SEPARATOR) if (allImages.isNotEmpty()) { - appendLine("- 사용된 이미지:") + appendLine(Strings.Footer.USED_IMAGES_TITLE) allImages.forEach { appendLine(" - $it") } } - - appendLine("\n\n[이 글의 작성 과정]") - append("이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ") - if (userPrompt.isNotBlank()) append("'${userPrompt.take(30)}...' 스타일로 문체와 구성을 다듬도록 요청하여, ") - append("${modelName} AI 모델의 도움을 받아 완성되었습니다.") + appendLine(Strings.Footer.PROCESS_TITLE) + val postscript = if (userPrompt.isNotBlank()) Strings.Footer.directPost(userPrompt, modelName) + else Strings.Footer.directPostNoPrompt(modelName) + append(postscript) } + val contextBuilder = StringBuilder() + contextBuilder.appendLine("\n\n---") + contextBuilder.appendLine("[Generation Context & Prompts]") + contextBuilder.appendLine("- Model: $modelName") + contextBuilder.appendLine("- Generation Type: Direct Posting") + contextBuilder.appendLine("\n[Inputs]") + contextBuilder.appendLine("- User Content Summary: ${userOwnContent.take(100)}...") + contextBuilder.appendLine("- Style Request: $userPrompt") + contextBuilder.appendLine("- Attached Images: ${uploadedImageFiles.joinToString { it.name }}") + contextBuilder.appendLine("\n[Underlying Prompt]") + contextBuilder.appendLine("--- Prefix ---") + contextBuilder.appendLine(generatePromptPrefix) + contextBuilder.appendLine("\n--- Instructions ---") + generatePromptInstructions.forEach { contextBuilder.appendLine("- $it") } + generationContextForClipboard = contextBuilder.toString() + blogPostResult = resultText blogPostFooter = footer - tabIndex = 4 + tabIndex = tabs.indexOf("블로그 결과") } finally { isLoading = false } } } @@ -220,10 +361,10 @@ fun App(imageLoader: ImageLoader) { isLoading = true val resultFlow = MutableStateFlow("") val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } } - analyzeReceipts(receiptFiles, apiKey, receiptWorkspaceSlug, receiptContextPrompt,logMessages, resultFlow) + analyzeReceipts(receiptFiles, apiKey, receiptWorkspaceSlug, receiptContextPrompt, receiptPromptBase, logMessages, resultFlow) uiUpdateJob.cancel() - } catch (e: CancellationException) { logMessage(logMessages, "ℹ️ 영수증 분석이 사용자에 의해 중단되었습니다."); receiptAnalysisResult = "분석이 중단되었습니다." - } catch (e: Exception) { logMessage(logMessages, "❌ 영수증 분석 중 심각한 오류 발생: ${e.message}"); receiptAnalysisResult = "오류 발생: ${e.message}" + } catch (e: CancellationException) { logMessage(logMessages, Strings.LOG_INFO_ANALYSIS_CANCELLED); receiptAnalysisResult = Strings.ANALYSIS_STOPPED + } catch (e: Exception) { logMessage(logMessages, Strings.logAnalysisError(e.message)); receiptAnalysisResult = Strings.analysisError(e.message) } finally { isLoading = false; analysisJob = null } } }, @@ -237,14 +378,11 @@ fun App(imageLoader: ImageLoader) { onRevisionRequestChange = { revisionRequest = it }, isLoading = isLoading, onRevise = { - if (apiKey.isBlank() || workspaceSlug.isBlank()) { - logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요.") - return@ResultTab - } + if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@ResultTab } coroutineScope.launch(Dispatchers.IO) { isLoading = true try { - val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug,logMessages) + val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug, revisePromptPrefix, logMessages) blogPostResult = revisedText revisionRequest = "" } finally { @@ -253,11 +391,40 @@ fun App(imageLoader: ImageLoader) { } }, onCopyToClipboard = { - val fullContent = blogPostResult + blogPostFooter + val fullContent = blogPostResult + blogPostFooter + generationContextForClipboard val stringSelection = StringSelection(fullContent) - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(stringSelection, null) - logMessage(logMessages, "✅ 블로그 전체 내용(꼬리말 포함)이 클립보드에 복사되었습니다.") + Toolkit.getDefaultToolkit().systemClipboard.setContents(stringSelection, null) + logMessage(logMessages, Strings.LOG_CLIPBOARD_COPY_SUCCESS) + } + ) + 5 -> SettingsTab( + generatePromptPrefix = generatePromptPrefix, + onGeneratePromptPrefixChange = { generatePromptPrefix = it }, + generatePromptInstructions = generatePromptInstructions, + onGeneratePromptInstructionsChange = { generatePromptInstructions = it }, + revisePrompt = revisePromptPrefix, + onRevisePromptChange = { revisePromptPrefix = it }, + receiptPrompt = receiptPromptBase, + onReceiptPromptChange = { receiptPromptBase = it }, + isLoading = isLoading, + // ⭐️ [추가] SettingsTab에 셀렉터 상태와 핸들러 전달 + articleSelectors = articleSelectors, + onArticleSelectorsChange = { articleSelectors = it }, + onSave = { + prefs.put(PREF_PROMPT_GENERATE, generatePromptPrefix) + prefs.put(PREF_PROMPT_REVISE, revisePromptPrefix) + prefs.put(PREF_PROMPT_RECEIPT, receiptPromptBase) + prefs.put(PREF_PROMPT_GENERATE_INSTRUCTIONS, generatePromptInstructions.joinToString("\n")) + prefs.put(PREF_ARTICLE_SELECTORS, articleSelectors.joinToString("\n")) // ⭐️ [수정] 저장 버튼 클릭 시에도 저장되도록 명시 (LaunchedEffect와 중복되지만 안전장치) + logMessage(logMessages, "✅ 프롬프트 및 설정이 저장되었습니다.") + }, + onReset = { + generatePromptPrefix = Strings.Prompts.GENERATE_POST_PREFIX + revisePromptPrefix = Strings.Prompts.REVISE_POST_PREFIX + receiptPromptBase = Strings.Prompts.RECEIPT_ANALYSIS_BASE + generatePromptInstructions = Strings.Prompts.DEFAULT_GENERATE_POST_INSTRUCTIONS + articleSelectors = Strings.DEFAULT_ARTICLE_SELECTORS // ⭐️ [추가] 초기화 시 셀렉터도 기본값으로 변경 + logMessage(logMessages, "ℹ️ 프롬프트 및 설정을 기본값으로 초기화했습니다.") } ) } diff --git a/src/main/kotlin/ui/tabs.kt b/src/main/kotlin/ui/tabs.kt index f33a24e..550e0a4 100644 --- a/src/main/kotlin/ui/tabs.kt +++ b/src/main/kotlin/ui/tabs.kt @@ -1,4 +1,4 @@ -// ui/tabs/ScrapBasedPostTab.kt +// ui/tabs/tabs.kt package ui.tabs import androidx.compose.foundation.Image @@ -8,10 +8,12 @@ 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.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -// (필요한 import 추가) import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -20,15 +22,17 @@ 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 utils.Strings import java.io.File @Composable fun ScrapBasedPostTab( isLoading: Boolean, keywords: List, searchResults: List, scrapedFiles: List, selectedFiles: Set, - viewedFileContent: String, imagesForSelection: List, currentSelectedImages: Set, + viewedFileContent: String, + combinedImagesFromSelectedFiles: List, + currentSelectedImages: Set, 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, @@ -40,12 +44,12 @@ fun ScrapBasedPostTab( // 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) + OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text(Strings.LABEL_KEYWORD_INPUT) }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading) Spacer(Modifier.width(4.dp)) - Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text("검색") } + Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text(Strings.BUTTON_SEARCH) } } Spacer(Modifier.height(8.dp)) - Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") } + Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text(Strings.BUTTON_FETCH_TRENDS) } 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)) } @@ -53,7 +57,7 @@ fun ScrapBasedPostTab( } // 2. 검색 결과 및 스크랩 Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(4.dp)) { - Text("검색 결과 (클릭하여 스크랩)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) + Text(Strings.TITLE_SEARCH_RESULTS, 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)) { @@ -68,8 +72,8 @@ fun ScrapBasedPostTab( // 저장된 파일 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("새로고침") } + Text(Strings.TITLE_SAVED_FILES, style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp)) + Button(onClick = onRefreshFiles, enabled = !isLoading) { Text(Strings.BUTTON_REFRESH) } } Box(modifier = Modifier.heightIn(max = 200.dp)) { LazyColumn { @@ -82,15 +86,15 @@ fun ScrapBasedPostTab( } } Spacer(Modifier.height(8.dp)) - Text("파일 내용", style = MaterialTheme.typography.subtitle1) + Text(Strings.TITLE_FILE_CONTENT, 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("선택 이미지 저장") } + Text(Strings.TITLE_SELECT_IMAGES, style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f)) + Button(onClick = onSaveChanges, enabled = !isLoading && combinedImagesFromSelectedFiles.isNotEmpty()) { Text(Strings.BUTTON_SAVE_IMAGE_SELECTION) } } LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) { - items(imagesForSelection) { imageUrl -> + items(combinedImagesFromSelectedFiles) { 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) @@ -102,23 +106,23 @@ fun ScrapBasedPostTab( // 글 생성 제어 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("글 스타일, 톤앤매너 등을 지시하세요.") }) + Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6) + OutlinedTextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_PROMPT) }) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = userMainTopic, onValueChange = onUserMainTopicChange, modifier = Modifier.fillMaxWidth(), - label = { Text("글의 핵심 주제 (예: 2025년 최신 IT 트렌드)") }, + label = { Text(Strings.LABEL_MAIN_TOPIC) }, singleLine = true ) Spacer(Modifier.height(8.dp)) - OutlinedTextField(value = userScrapComment, onValueChange = onUserScrapCommentChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text("작성자 코멘트 (스크랩한 내용에 대한 당신의 생각)") }) + OutlinedTextField(value = userScrapComment, onValueChange = onUserScrapCommentChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_COMMENT) }) Spacer(Modifier.height(8.dp)) Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { - Text("선택한 파일(${selectedFiles.size}개)로 글 생성") + Text(Strings.buttonGeneratePost(selectedFiles.size)) } } } @@ -145,19 +149,19 @@ fun DirectPostTab( 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)) + Text(Strings.TITLE_DIRECT_POST, style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) OutlinedTextField( value = userOwnContent, onValueChange = onUserOwnContentChange, modifier = Modifier.fillMaxSize(), - label = { Text("블로그에 올릴 내용을 직접 작성하세요.") } + label = { Text(Strings.LABEL_DIRECT_POST_CONTENT) } ) } // 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에서 이미지 업로드") } + Text(Strings.TITLE_IMAGE_UPLOAD, style = MaterialTheme.typography.h6) + Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_IMAGE) } Spacer(Modifier.height(8.dp)) LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { items(uploadedImageFiles) { file -> @@ -173,19 +177,19 @@ fun DirectPostTab( } Spacer(Modifier.height(16.dp)) - Text("LLM 요청사항", style = MaterialTheme.typography.h6) + Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6) OutlinedTextField( value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(150.dp), - label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") } + label = { Text(Strings.LABEL_USER_PROMPT) } ) Spacer(Modifier.height(8.dp)) Button( onClick = onGeneratePost, enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth() - ) { Text("작성한 내용으로 글 생성") } + ) { Text(Strings.BUTTON_GENERATE_POST_DIRECT) } } } if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } @@ -209,8 +213,8 @@ fun ReceiptAnalyzerTab( ) { 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("영수증 이미지 업로드") } + Text(Strings.TITLE_RECEIPT_ANALYZER, style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 8.dp)) + Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_RECEIPT) } Spacer(Modifier.height(8.dp)) LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { items(receiptFiles) { file -> @@ -229,8 +233,8 @@ fun ReceiptAnalyzerTab( value = receiptContextPrompt, onValueChange = onReceiptContextPromptChange, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("추가 정보를 입력하면 더 정확하게 분석할 수 있습니다.") }, - label = { Text("추가 정보 입력 (예: 부산 출장 경비)") } + placeholder = { Text(Strings.PLACEHOLDER_RECEIPT_CONTEXT) }, + label = { Text(Strings.LABEL_RECEIPT_CONTEXT) } ) Spacer(Modifier.height(16.dp)) if (isAnalyzing) { @@ -238,12 +242,12 @@ fun ReceiptAnalyzerTab( Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) Spacer(Modifier.width(8.dp)) - Text("분석 중단하기") + Text(Strings.BUTTON_CANCEL_ANALYSIS) } } } else { Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { - Text("선택한 영수증 분석 시작 (${receiptFiles.size}개)") + Text(Strings.buttonStartAnalysis(receiptFiles.size)) } } Spacer(Modifier.height(16.dp)) @@ -252,7 +256,7 @@ fun ReceiptAnalyzerTab( onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize(), - label = { Text("분석 결과 (내용 복사하여 사용)") } + label = { Text(Strings.LABEL_ANALYSIS_RESULT) } ) } if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } @@ -281,15 +285,15 @@ fun ResultTab( value = result, onValueChange = onRequestResultChange, modifier = Modifier.weight(1f).fillMaxWidth(), - label = { Text("블로그 글 결과 (LLM 생성 본문)") } + label = { Text(Strings.LABEL_BLOG_RESULT) } ) Spacer(Modifier.height(16.dp)) OutlinedTextField( value = revisionRequest, onValueChange = onRevisionRequestChange, modifier = Modifier.fillMaxWidth().height(100.dp), - label = { Text("추가 요청사항") }, - placeholder = { Text("예: 문체를 좀 더 전문적으로 바꿔줘. 1번 항목을 더 자세히 설명해줘.") }, + label = { Text(Strings.LABEL_REVISION_REQUEST) }, + placeholder = { Text(Strings.PLACEHOLDER_REVISION_REQUEST) }, enabled = !isLoading ) Spacer(Modifier.height(8.dp)) @@ -302,7 +306,7 @@ fun ResultTab( if (isLoading) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp) } else { - Text("LLM으로 글 보완하기") + Text(Strings.BUTTON_REVISE_POST) } } Spacer(Modifier.width(8.dp)) @@ -311,9 +315,172 @@ fun ResultTab( enabled = !isLoading && result.isNotBlank(), modifier = Modifier.weight(1f) ) { - Text("전체 내용 클립보드에 복사") + Text(Strings.BUTTON_COPY_TO_CLIPBOARD) } } } } } + +// ⭐️ [수정] SettingsTab 함수 시그니처 변경 +@Composable +fun SettingsTab( + generatePromptPrefix: String, + onGeneratePromptPrefixChange: (String) -> Unit, + generatePromptInstructions: List, + onGeneratePromptInstructionsChange: (List) -> Unit, + revisePrompt: String, + onRevisePromptChange: (String) -> Unit, + receiptPrompt: String, + onReceiptPromptChange: (String) -> Unit, + articleSelectors: List, + onArticleSelectorsChange: (List) -> Unit, + onSave: () -> Unit, + onReset: () -> Unit, + isLoading: Boolean +) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text("프롬프트 및 설정", style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 16.dp)) + + // --- 블로그 글 생성 프롬프트 섹션 --- + Text("블로그 글 생성 프롬프트", style = MaterialTheme.typography.h6) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = generatePromptPrefix, + onValueChange = onGeneratePromptPrefixChange, + modifier = Modifier.fillMaxWidth().height(150.dp), + label = { Text("기본 역할 (Prefix)") }, + enabled = !isLoading + ) + Spacer(Modifier.height(8.dp)) + Text("요청사항 목록", style = MaterialTheme.typography.subtitle1) + + // LazyColumn은 Column 내에서 높이가 지정되어야 하므로 Box로 감싸서 제한 + Box(modifier = Modifier.heightIn(max = 250.dp)) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(generatePromptInstructions) { index, instruction -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = instruction, + onValueChange = { newText -> + val newList = generatePromptInstructions.toMutableList() + newList[index] = newText + onGeneratePromptInstructionsChange(newList) + }, + modifier = Modifier.weight(1f), + singleLine = true, + enabled = !isLoading + ) + Spacer(Modifier.width(8.dp)) + IconButton(onClick = { + val newList = generatePromptInstructions.toMutableList() + newList.removeAt(index) + onGeneratePromptInstructionsChange(newList) + }, enabled = !isLoading) { + Icon(Icons.Default.Delete, contentDescription = "삭제") + } + } + } + } + } + Button( + onClick = { onGeneratePromptInstructionsChange(generatePromptInstructions + "") }, + enabled = !isLoading + ) { + Text("요청사항 항목 추가") + } + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + // ⭐️ [추가] 스크래핑 CSS 셀렉터 설정 UI + Text("아티클 스크래핑 CSS 셀렉터", style = MaterialTheme.typography.h6) + Text( + "스크랩 시 본문을 찾기 위해 사용되는 CSS 셀렉터 목록입니다. 우선순위가 높은 것을 위로 배치하세요.", + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(bottom = 8.dp) + ) + Box(modifier = Modifier.heightIn(max = 250.dp)) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(articleSelectors) { index, selector -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = selector, + onValueChange = { newText -> + val newList = articleSelectors.toMutableList() + newList[index] = newText + onArticleSelectorsChange(newList) + }, + modifier = Modifier.weight(1f), + singleLine = true, + enabled = !isLoading + ) + Spacer(Modifier.width(8.dp)) + IconButton(onClick = { + val newList = articleSelectors.toMutableList() + newList.removeAt(index) + onArticleSelectorsChange(newList) + }, enabled = !isLoading) { + Icon(Icons.Default.Delete, contentDescription = "셀렉터 삭제") + } + } + } + } + } + Button( + onClick = { onArticleSelectorsChange(articleSelectors + "") }, + enabled = !isLoading + ) { + Text("셀렉터 항목 추가") + } + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + + // --- 글 수정 및 영수증 분석 프롬프트 --- + OutlinedTextField( + value = revisePrompt, + onValueChange = onRevisePromptChange, + modifier = Modifier.fillMaxWidth().height(150.dp), + label = { Text("블로그 글 수정 프롬프트") }, + enabled = !isLoading + ) + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = receiptPrompt, + onValueChange = onReceiptPromptChange, + modifier = Modifier.fillMaxWidth().height(150.dp), + label = { Text("영수증 분석 프롬프트") }, + enabled = !isLoading + ) + Spacer(Modifier.height(24.dp)) + + // --- 저장 및 초기화 버튼 --- + Row(modifier = Modifier.fillMaxWidth()) { + Button(onClick = onSave, enabled = !isLoading, modifier = Modifier.weight(1f)) { + Text("설정 저장하기") + } + Spacer(Modifier.width(16.dp)) + Button( + onClick = onReset, + enabled = !isLoading, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary) + ) { + Text("기본값으로 초기화") + } + } + } + if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/widgets/FileDialog.kt b/src/main/kotlin/ui/widgets/FileDialog.kt index 350657e..eb42a62 100644 --- a/src/main/kotlin/ui/widgets/FileDialog.kt +++ b/src/main/kotlin/ui/widgets/FileDialog.kt @@ -4,6 +4,7 @@ package ui.widgets import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import utils.Strings import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -11,7 +12,7 @@ import java.io.File @Composable fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List) -> Unit) { val fileDialog = remember { - FileDialog(parent, "파일 선택", FileDialog.LOAD).apply { + FileDialog(parent, Strings.FILE_DIALOG_TITLE, FileDialog.LOAD).apply { isMultipleMode = true } } diff --git a/src/main/kotlin/utils/Global.kt b/src/main/kotlin/utils/Global.kt index 0dff33d..9c922ed 100644 --- a/src/main/kotlin/utils/Global.kt +++ b/src/main/kotlin/utils/Global.kt @@ -6,7 +6,13 @@ 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.encodeToString import kotlinx.serialization.json.Json +import models.ScrapedData +import org.openqa.selenium.WebDriverException +import org.openqa.selenium.chrome.ChromeDriver +import org.openqa.selenium.chrome.ChromeOptions +import java.io.File import java.text.SimpleDateFormat import java.util.* import java.util.prefs.Preferences @@ -25,9 +31,272 @@ object Global { const val PREF_WORKSPACE_SLUG = "workspace_slug" const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" const val PREF_MODEL_NAME = "model_name" + + // ⭐️ [추가] 프롬프트 저장을 위한 키 + const val PREF_PROMPT_GENERATE = "prompt_generate" + const val PREF_PROMPT_REVISE = "prompt_revise" + const val PREF_PROMPT_RECEIPT = "prompt_receipt" + + const val PREF_PROMPT_GENERATE_INSTRUCTIONS = "prompt_generate_instructions" + + // ⭐️ [추가] 스크래핑 셀렉터 저장을 위한 키 + const val PREF_ARTICLE_SELECTORS = "article_selectors" + + } fun logMessage(logs: MutableList, message: String) { val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) logs.add(0, "$timestamp: $message") +} + + +object FileManager { + + fun saveDataToJsonFile(keyword: String, data: ScrapedData, folderPath: String, logs: MutableList): 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, Strings.logSavingScrapData(file.path)) + return file + } + + fun loadScrapedJsonFiles(folderPath: String, logs: MutableList): List { + logMessage(logs, Strings.logLoadingFiles(folderPath)) + return File(folderPath).listFiles { _, name -> name.endsWith(".json") } + ?.sortedByDescending { it.lastModified() } + .orEmpty() + .also { logMessage(logs, Strings.logLoadedFiles(it.size)) } + } +} + + +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 + } +} +/** + * 애플리케이션 전체에서 사용되는 하드코딩된 문자열을 관리하는 객체입니다. + */ +object Strings { + + // --- 기본 정보 --- + const val APP_TITLE = "자동 블로그 포스팅 도우미 v1.0" + + // --- 기본값 --- + const val DEFAULT_SCRAPED_FOLDER = "scraped_articles" + const val DEFAULT_WORKSPACE_SLUG = "my-workspace" + const val DEFAULT_RECEIPT_WORKSPACE_SLUG = "receipts" + const val DEFAULT_MODEL_NAME = "Llama-3.1-8B-Vision" + const val DEFAULT_USER_PROMPT = "개인 기록용으로 가볍게 남기는 스타일로 작성해줘." + const val DEFAULT_USER_OWN_CONTENT = "예시: 강릉으로 1박 2일 여행을 다녀왔습니다." + // ⭐️ [추가] 스크래핑 셀렉터 기본값 + val DEFAULT_ARTICLE_SELECTORS = listOf( + "#postListBody", + "#app", + ".app", + "article", + ".article-body", + "#article_body", + ".news-article-body-view" + ) + + // --- UI 텍스트: 공통 --- + val TABS = listOf("스크랩 기반 포스팅", "직접 포스팅", "영수증 분석기", "통신 로그", "블로그 결과") + const val BUTTON_REFRESH = "새로고침" + const val LABEL_LLM_REQUEST = "LLM 요청사항" + const val LABEL_USER_PROMPT = "글 스타일, 톤앤매너 등을 지시하세요." + const val PLACEHOLDER_API_KEY = "AnythingLLM API Key" + + // --- UI 텍스트: App.kt (환경설정) --- + const val LABEL_BROWSER_VISIBLE = "브라우저 화면 보기" + const val LABEL_BROWSER_SESSION = "브라우저 세션 유지" + const val LABEL_SCRAP_FOLDER_PATH = "스크랩 저장 폴더 경로" + const val LABEL_BLOG_WORKSPACE_SLUG = "블로그용 Workspace Slug" + const val LABEL_RECEIPT_WORKSPACE_SLUG = "영수증 처리용 Workspace Slug" + const val LABEL_MODEL_NAME = "사용 중인 LLM 모델 이름" + + // --- UI 텍스트: 스크랩 기반 포스팅 탭 --- + const val LABEL_KEYWORD_INPUT = "키워드 직접 입력" + const val BUTTON_SEARCH = "검색" + const val BUTTON_FETCH_TRENDS = "트렌드 가져오기" + const val TITLE_SEARCH_RESULTS = "검색 결과 (클릭하여 스크랩)" + const val TITLE_SAVED_FILES = "저장된 파일" + const val TITLE_FILE_CONTENT = "파일 내용" + const val TITLE_SELECT_IMAGES = "대표 이미지 선택 (다중 가능)" + const val BUTTON_SAVE_IMAGE_SELECTION = "선택 이미지 저장" + const val LABEL_MAIN_TOPIC = "글의 핵심 주제 (예: 2025년 최신 IT 트렌드)" + const val LABEL_USER_COMMENT = "작성자 코멘트 (스크랩한 내용에 대한 당신의 생각)" + fun buttonGeneratePost(count: Int) = "선택한 파일(${count}개)로 글 생성" + + // --- UI 텍스트: 직접 포스팅 탭 --- + const val TITLE_DIRECT_POST = "직접 작성 (여행 기록, 정보 공유 등)" + const val LABEL_DIRECT_POST_CONTENT = "블로그에 올릴 내용을 직접 작성하세요." + const val TITLE_IMAGE_UPLOAD = "이미지 업로드" + const val BUTTON_UPLOAD_IMAGE = "내 PC에서 이미지 업로드" + const val BUTTON_GENERATE_POST_DIRECT = "작성한 내용으로 글 생성" + + // --- UI 텍스트: 영수증 분석기 탭 --- + const val TITLE_RECEIPT_ANALYZER = "영수증 분석기" + const val BUTTON_UPLOAD_RECEIPT = "영수증 이미지 업로드" + const val LABEL_RECEIPT_CONTEXT = "추가 정보 입력 (예: 부산 출장 경비)" + const val PLACEHOLDER_RECEIPT_CONTEXT = "추가 정보를 입력하면 더 정확하게 분석할 수 있습니다." + const val BUTTON_CANCEL_ANALYSIS = "분석 중단하기" + const val LABEL_ANALYSIS_RESULT = "분석 결과 (내용 복사하여 사용)" + fun buttonStartAnalysis(count: Int) = "선택한 영수증 분석 시작 (${count}개)" + const val ANALYSIS_STOPPED = "분석이 중단되었습니다." + fun analysisError(message: String?) = "오류 발생: $message" + + // --- UI 텍스트: 결과 탭 --- + const val LABEL_BLOG_RESULT = "블로그 글 결과 (LLM 생성 본문)" + const val LABEL_REVISION_REQUEST = "추가 요청사항" + const val PLACEHOLDER_REVISION_REQUEST = "예: 문체를 좀 더 전문적으로 바꿔줘. 1번 항목을 더 자세히 설명해줘." + const val BUTTON_REVISE_POST = "LLM으로 글 보완하기" + const val BUTTON_COPY_TO_CLIPBOARD = "전체 내용 클립보드에 복사" + + // --- UI 텍스트: 플레이스홀더 --- + const val PLACEHOLDER_BLOG_POST = "LLM으로부터 생성된 블로그 글이 여기에 표시됩니다." + const val PLACEHOLDER_FILE_CONTENT = "파일을 선택하면 내용이 여기에 표시됩니다." + const val PLACEHOLDER_RECEIPT_ANALYSIS = "영수증을 업로드하고 분석을 시작하세요." + + // --- UI 텍스트: FileDialog --- + const val FILE_DIALOG_TITLE = "파일 선택" + + // --- 로그 메시지 --- + fun logImageFilesAdded(count: Int) = "✅ 개인 이미지 파일 ${count}개 추가됨." + fun logReceiptsAdded(count: Int) = "🧾 영수증 이미지 ${count}개 추가됨." + fun logReadFileError(message: String?) = "❌ 파일 읽기 오류: $message" + fun logSaveFileError(fileName: String, message: String?) = "❌ '$fileName' 저장 중 오류: $message" + fun logImageSelectionSaved(fileName: String) = "✅ '${fileName}'의 선택 이미지 변경사항 저장 완료." + + const val LOG_WARN_NO_FILES_TO_SAVE = "⚠️ 저장할 파일을 선택해주세요." + const val LOG_WARN_MISSING_API_CONFIG = "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요." + const val LOG_WARN_NO_FILES_SELECTED = "⚠️ 글을 생성할 스크랩 파일을 1개 이상 선택해주세요." + const val LOG_WARN_UPLOAD_FAILED = "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다." + const val LOG_WARN_DIRECT_POST_EMPTY = "⚠️ 직접 포스팅을 하려면 내용과 이미지가 모두 필요합니다." + const val LOG_INFO_ANALYSIS_CANCELLED = "ℹ️ 영수증 분석이 사용자에 의해 중단되었습니다." + fun logAnalysisError(message: String?) = "❌ 영수증 분석 중 심각한 오류 발생: $message" + const val LOG_CLIPBOARD_COPY_SUCCESS = "✅ 블로그 전체 내용(꼬리말 및 생성 정보 포함)이 클립보드에 복사되었습니다." + fun logLoadingFiles(path: String) = "'$path'에서 파일 목록 로딩..." + fun logLoadedFiles(count: Int) = "✅ 파일 ${count}개 로딩 완료." + fun logSavingScrapData(path: String) = "✅ '${path}'에 스크랩 데이터 저장 완료." + const val LOG_TRENDS_START = "Google Trends 페이지 스크랩 시작..." + fun logTrendsSuccess(count: Int) = "✅ Google Trends 키워드 ${count}개 스크랩 완료." + fun logTrendsError(message: String?) = "❌ Google Trends 스크랩 오류: $message" + fun logSearchStart(keyword: String) = "'$keyword' 키워드로 Google 검색 시작..." + fun logSearchSuccess(keyword: String, count: Int) = "✅ '$keyword' 검색 결과 ${count}개 수집 완료." + fun logSearchError(message: String?) = "❌ Google 검색 중 오류: $message" + fun logScrapeUrlStart(url: String) = "URL 스크랩 시작: $url" + const val LOG_WARN_ARTICLE_BODY_NOT_FOUND = "⚠️ 기사 본문을 찾을 수 없습니다." + fun logScrapeUrlSuccess(imageCount: Int) = "✅ URL 스크랩 완료. (이미지 ${imageCount}개 발견)" + fun logScrapeUrlError(message: String?) = "❌ URL 스크랩 중 오류: $message" + fun logLlmUploadStarting(count: Int) = "LLM에 파일 ${count}개 순차 업로드 시작..." + fun logLlmUploadingFile(name: String) = " - '${name}' 업로드 중..." + fun logLlmUploadSuccess(name: String, id: String) = " ✅ '${name}' 업로드 성공 (ID: $id)." + const val LOG_LLM_UPLOAD_NO_DOC_INFO = "업로드 응답에 문서 정보가 없습니다." + fun logLlmUploadResponseError(name: String, status: Any, body: String) = " ❌ '${name}' 업로드 실패: $status - $body" + fun logLlmUploadException(name: String, message: String?) = " ❌ '${name}' 업로드 중 오류: $message" + fun logLlmUploadFinished(total: Int, success: Int) = "총 ${total}개 중 ${success}개 파일 업로드 완료." + const val LOG_LLM_POST_GENERATION_START = "LLM 블로그 글 생성 요청..." + const val LOG_LLM_POST_GENERATION_SUCCESS = "✅ LLM 블로그 글 생성 완료." + fun logLlmApiError(message: String?) = "❌ LLM 호출 중 오류: $message" + const val LOG_LLM_REVISION_START = "LLM 블로그 글 수정 요청..." + const val LOG_LLM_REVISION_EMPTY = "⚠️ 수정 요청사항이 비어있어 수정을 중단합니다." + const val LOG_LLM_REVISION_SUCCESS = "✅ LLM 블로그 글 수정 완료." + fun logLlmCleanupStart(count: Int) = "LLM 워크스페이스 정리 시작 (파일 ${count}개 삭제)..." + const val LOG_LLM_CLEANUP_SUCCESS = "✅ LLM 워크스페이스 정리 완료." + fun logLlmCleanupError(status: Any, body: String) = "❌ LLM 워크스페이스 정리 실패: $status - $body" + fun logLlmCleanupException(message: String?) = "❌ LLM 워크스페이스 정리 중 오류: $message" + fun logReceiptAnalysisMissing(missing: String) = "⚠️ 영수증 분석을 시작할 수 없습니다. ($missing 누락)" + const val LOG_RECEIPT_ANALYSIS_START = "Starting receipt analysis with stream-chat mode..." + const val LOG_RECEIPT_STREAM_FINALIZED = "✅ Stream finalized." + fun logReceiptStreamMetrics(tokens: Int, duration: Double?) = " - Metrics: total_tokens=${tokens}, duration=${duration}s" + fun logReceiptStreamAborted(reason: String?) = "⚠️ Stream aborted by server. Reason: $reason" + fun logReceiptChunkParsingError(message: String?, chunk: String) = "❌ Chunk parsing error: $message | Chunk: $chunk" + const val LOG_RECEIPT_STREAM_FINISHED = "Receipt analysis stream processing finished." + fun logReceiptApiError(status: Any, body: String) = "❌ Error during receipt analysis: $status - $body" + fun logReceiptException(message: String?) = "❌ Exception in receipt analysis: $message" + fun logImageSelectionSavedToFiles(count: Int) = "✅ 선택된 ${count}개 파일에 이미지 선택 변경사항 저장 완료." + + // --- API 프롬프트 --- + object Prompts { + // ⭐️ [수정] 프롬프트의 기본 지시사항(역할)을 상수로 분리 + const val GENERATE_POST_PREFIX = "당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요." + const val REVISE_POST_PREFIX = "당신은 주어진 글을 사용자의 요청에 맞게 수정하는 전문 편집자입니다. 아래의 원본 글을 수정 요청사항에 따라 개선해주세요. 원본의 주제와 주요 내용은 유지하되, 요청을 충실히 반영하여 더 나은 글로 만들어주세요. 마크다운 형식은 유지해주세요." + const val RECEIPT_ANALYSIS_BASE = "첨부된 영수증 이미지들을 분석해줘." + + // ⭐️ [수정] basePrompt를 인자로 받도록 변경 + fun receiptAnalysisWithContext(basePrompt: String, context: String) = """$basePrompt --- 추가 정보 --- $context --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘.""" + + val DEFAULT_GENERATE_POST_INSTRUCTIONS = listOf( + "제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.", + "글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요.", + "가능한 자연스럽고 유머러스하게 글을 작성해주세요.", + "주제에 벗어나지 않고 해당 주제에 포커스를 맞춰서 글을 작성해주세요." + ) + + // ⭐️ [수정] generateBlogPost 함수가 요청사항 리스트를 동적으로 조립하도록 변경 + fun generateBlogPost( + basePrompt: String, + userDirection: String, + instructions: List, // List을 인자로 받음 + contentSourcePromptPart: String, + commentPromptPart: String, + mainTopicPromptPart: String, + imagePromptPart: String + ): String { + // 동적 요청사항과 사용자 정의 요청사항을 합쳐서 최종 리스트 생성 + val finalInstructions = mutableListOf() + finalInstructions.add("사용자 요청: \"$userDirection\"") + finalInstructions.addAll(instructions) + if (imagePromptPart.isNotBlank()) { + // 이미지 삽입 요청은 항상 마지막 부분에 추가되도록 조정 + finalInstructions.add(imagePromptPart.replaceFirst("4. ", "")) + } + + // 최종 리스트를 번호와 함께 문자열로 변환 + val instructionsText = finalInstructions.mapIndexed { index, text -> "${index + 1}. $text" }.joinToString("\n") + + return """ + $basePrompt + $contentSourcePromptPart + $commentPromptPart + $mainTopicPromptPart + --- 요청사항 --- + $instructionsText + """.trimIndent() + } + } + + // --- 포스트 꼬리말 --- + object Footer { + const val SEPARATOR = "\n\n---" + const val ORIGINAL_SOURCE_TITLE = "- 원문 출처:" + const val USED_IMAGES_TITLE = "\n- 사용된 이미지:" + const val PROCESS_TITLE = "\n\n[이 글의 작성 과정]" + fun scrapBased(contextSummary: String, modelName: String) = "이 포스팅은 ${contextSummary}등의 정보를 바탕으로, 여러 참고 자료를 종합하여 ${modelName} AI 모델이 초안을 생성했습니다. 이후 작성자의 검토를 거쳐 수정 및 발행되었습니다." + fun directPost(userPrompt: String, modelName: String) = "이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, '${userPrompt.take(30)}...' 스타일로 문체와 구성을 다듬도록 요청하여, ${modelName} AI 모델의 도움을 받아 완성되었습니다." + fun directPostNoPrompt(modelName: String) = "이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ${modelName} AI 모델의 도움을 받아 완성되었습니다." + fun contextTopic(topic: String) = "주제: '${topic}'" + fun contextComment(comment: String) = "작성자 코멘트: '${comment.take(30)}...'" + fun contextStyle(prompt: String) = "요청 스타일: '${prompt.take(30)}...'" + } } \ No newline at end of file