This commit is contained in:
lunaticbum 2025-10-13 16:18:55 +09:00
parent 6d66a0147c
commit 92aa1a998e
9 changed files with 818 additions and 288 deletions

View File

@ -1,62 +1,12 @@
import androidx.compose.foundation.Image // Main.kt
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
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.rememberAsyncImagePainter
import coil3.network.okhttp.OkHttpNetworkFetcherFactory 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 ui.App
import utils.BrowserManager.quitChromeDriver
import utils.Global.httpClient import utils.Global.httpClient
import java.awt.FileDialog import utils.Strings
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
fun main() = application { fun main() = application {
@ -65,7 +15,7 @@ fun main() = application {
.build() .build()
Window( Window(
onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() }, onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() },
title = "자동 블로그 포스팅 도우미 v14.0 (Contextual Footer)" // ⭐️ [수정] 버전명 변경 title = Strings.APP_TITLE
) { ) {
App(imageLoader) App(imageLoader)
} }

View File

@ -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
}
}

View File

@ -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<String>): File {
val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() }
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName)
file.writeText(Global.jsonParser.encodeToString(data))
logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.")
return file
}
fun loadScrapedJsonFiles(folderPath: String, logs: MutableList<String>): List<File> {
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
return File(folderPath).listFiles { _, name -> name.endsWith(".json") }
?.sortedByDescending { it.lastModified() }
.orEmpty()
.also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") }
}
}

View File

@ -12,18 +12,19 @@ import models.*
import utils.Global import utils.Global
import utils.Global.httpClient import utils.Global.httpClient
import utils.Global.jsonParser import utils.Global.jsonParser
import utils.Strings
import utils.logMessage import utils.logMessage
import java.io.File import java.io.File
import java.util.Base64 import java.util.*
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
object LlmApiService { object LlmApiService {
suspend fun uploadFiles(files: List<File>, apiKey: String, workspaceSlug: String, logs: MutableList<String>): List<String> { suspend fun uploadFiles(files: List<File>, apiKey: String, workspaceSlug: String, logs: MutableList<String>): List<String> {
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...") logMessage(logs, Strings.logLlmUploadStarting(files.size))
val uploadedDocIds = mutableListOf<String>() val uploadedDocIds = mutableListOf<String>()
files.forEach { file -> files.forEach { file ->
logMessage(logs, " - '${file.name}' 업로드 중...") logMessage(logs, Strings.logLlmUploadingFile(file.name))
try { try {
val response: HttpResponse = Global.httpClient.post("http://localhost:3001/api/v1/document/upload") { val response: HttpResponse = Global.httpClient.post("http://localhost:3001/api/v1/document/upload") {
header("Authorization", "Bearer $apiKey") header("Authorization", "Bearer $apiKey")
@ -35,11 +36,13 @@ object LlmApiService {
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val uploadResponse = response.body<UploadResponse>() val uploadResponse = response.body<UploadResponse>()
uploadResponse.documents.firstOrNull()?.id?.let { uploadedDocIds.add(it); logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).") } ?: logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.") uploadResponse.documents.firstOrNull()?.id?.let {
} else { logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}") } uploadedDocIds.add(it); logMessage(logs, Strings.logLlmUploadSuccess(file.name, it))
} catch (e: Exception) { logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}") } } ?: 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 return uploadedDocIds
} }
@ -52,9 +55,11 @@ object LlmApiService {
userMainTopic: String, userMainTopic: String,
apiKey: String, apiKey: String,
workspaceSlug: String, workspaceSlug: String,
generatePromptPrefix: String,
generatePromptInstructions: List<String>, // ⭐️ [추가] 요청사항 리스트 인자
logs: MutableList<String> logs: MutableList<String>
): String { ): String {
logMessage(logs, "LLM 블로그 글 생성 요청...") logMessage(logs, Strings.LOG_LLM_POST_GENERATION_START)
val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) { 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" } 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 imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n")}" else ""
val finalPrompt = """ // ⭐️ [수정] Strings.Prompts.generateBlogPost 호출 시 인자 추가
당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. val finalPrompt = Strings.Prompts.generateBlogPost(
$contentSourcePromptPart basePrompt = generatePromptPrefix,
$commentPromptPart userDirection = userDirection,
$mainTopicPromptPart instructions = generatePromptInstructions,
--- 요청사항 --- contentSourcePromptPart = contentSourcePromptPart,
1. 사용자 요청: "$userDirection" commentPromptPart = commentPromptPart,
2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요. mainTopicPromptPart = mainTopicPromptPart,
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3 추천해주세요. imagePromptPart = imagePromptPart
$imagePromptPart )
5. 가능한 자연스럽고 유머러스하게 글을 작성해주세요.
6. 주제에 벗어나지 않고 해당 주제에 포커스를 맞춰서 글을 작성해주세요.
""".trimIndent()
try { try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") { 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)) header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
} }
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText()) val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
logMessage(logs, "✅ LLM 블로그 글 생성 완료.") logMessage(logs, Strings.LOG_LLM_POST_GENERATION_SUCCESS)
return chatResponse.textResponse return chatResponse.textResponse
} catch (e: Exception) { } 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, revisionRequest: String,
apiKey: String, apiKey: String,
workspaceSlug: String, workspaceSlug: String,
revisePromptPrefix: String, // ⭐️ [수정] 프롬프트 인자 추가
logs: MutableList<String> logs: MutableList<String>
): String { ): String {
logMessage(logs, "LLM 블로그 글 수정 요청...") logMessage(logs, Strings.LOG_LLM_REVISION_START)
if (revisionRequest.isBlank()) { if (revisionRequest.isBlank()) {
logMessage(logs, "⚠️ 수정 요청사항이 비어있어 수정을 중단합니다.") logMessage(logs, Strings.LOG_LLM_REVISION_EMPTY)
return currentPost return currentPost
} }
// ⭐️ [수정] 인자로 받은 프롬프트를 사용
val finalPrompt = """ val finalPrompt = """
당신은 주어진 글을 사용자의 요청에 맞게 수정하는 전문 편집자입니다. 아래의 원본 글을 수정 요청사항에 따라 개선해주세요. 원본의 주제와 주요 내용은 유지하되, 요청을 충실히 반영하여 나은 글로 만들어주세요. 마크다운 형식은 유지해주세요. $revisePromptPrefix
--- 원본 --- --- 원본 ---
$currentPost $currentPost
@ -128,23 +132,23 @@ object LlmApiService {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt)) header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
} }
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText()) val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
logMessage(logs, "✅ LLM 블로그 글 수정 완료.") logMessage(logs, Strings.LOG_LLM_REVISION_SUCCESS)
return chatResponse.textResponse return chatResponse.textResponse
} catch (e: Exception) { } 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<String>, apiKey: String, logs: MutableList<String>) { suspend fun cleanupWorkspace(docIds: List<String>, apiKey: String, logs: MutableList<String>) {
if (docIds.isEmpty()) return if (docIds.isEmpty()) return
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...") logMessage(logs, Strings.logLlmCleanupStart(docIds.size))
try { try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") { 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 워크스페이스 정리 완료.") } if (response.status.isSuccess()) { logMessage(logs, Strings.LOG_LLM_CLEANUP_SUCCESS) }
else { logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}") } else { logMessage(logs, Strings.logLlmCleanupError(response.status, response.bodyAsText())) }
} catch (e: Exception) { logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}") } } catch (e: Exception) { logMessage(logs, Strings.logLlmCleanupException(e.message)) }
} }
suspend fun analyzeReceipts( suspend fun analyzeReceipts(
@ -152,18 +156,21 @@ object LlmApiService {
apiKey: String, apiKey: String,
receiptWorkspaceSlug: String, receiptWorkspaceSlug: String,
receiptContext: String, receiptContext: String,
receiptPromptBase: String, // ⭐️ [수정] 프롬프트 인자 추가
logs: MutableList<String>, logs: MutableList<String>,
resultState: MutableStateFlow<String> resultState: MutableStateFlow<String>
) { ) {
if (apiKey.isBlank() || receiptWorkspaceSlug.isBlank() || files.isEmpty()) { 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 = "영수증 분석 중..." resultState.value = "영수증 분석 중..."
try { 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 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", " ")}") logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}")
val requestBody = StreamChatRequest(message = finalPrompt, attachments = attachments, mode = "chat", sessionId = "receipt-analysis-${System.currentTimeMillis()}") 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") { httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") {
@ -180,16 +187,16 @@ object LlmApiService {
try { try {
when (val streamObject = jsonParser.decodeFromString<SealedLLMStreamResponse>(jsonChunk)) { when (val streamObject = jsonParser.decodeFromString<SealedLLMStreamResponse>(jsonChunk)) {
is SealedLLMStreamResponse.TextResponseChunk -> { val textChunk = streamObject.textResponse ?: ""; if (textChunk.isNotBlank() && textChunk != "-") { fullResponseText += textChunk; resultState.value = fullResponseText } } is SealedLLMStreamResponse.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.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, "⚠️ Stream aborted by server. Reason: ${streamObject.textResponse}"); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; 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.") logMessage(logs, Strings.LOG_RECEIPT_STREAM_FINISHED)
} else { val errorBody = response.bodyAsText(); logMessage(logs, "❌ Error during receipt analysis: ${response.status} - $errorBody"); resultState.value = "API 오류: ${response.status}" } } else { val errorBody = response.bodyAsText(); logMessage(logs, Strings.logReceiptApiError(response.status, errorBody)); resultState.value = "API 오류: ${response.status}" }
} }
} catch (e: CancellationException) { throw e } } 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 ?: "알 수 없는 오류" }
} }
} }

View File

@ -1,31 +1,34 @@
// core/ScrapingService.kt // core/ScrapingService.kt
package core package core
import models.SearchResult
import models.ScrapedData import models.ScrapedData
import models.SearchResult
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.openqa.selenium.By import org.openqa.selenium.By
import org.openqa.selenium.chrome.ChromeOptions import org.openqa.selenium.chrome.ChromeOptions
import utils.BrowserManager
import utils.Strings
import utils.logMessage import utils.logMessage
object ScrapingService { object ScrapingService {
suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<String> { suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<String> {
logMessage(logs, "Google Trends 페이지 스크랩 시작...") logMessage(logs, Strings.LOG_TRENDS_START)
val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR" 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 options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options) val currentDriver = BrowserManager.getChromeDriver(options)
return try { return try {
currentDriver.get(trendsUrl) currentDriver.get(trendsUrl)
Thread.sleep(5000) 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) { } 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() } } finally { if (!keepSession) BrowserManager.quitChromeDriver() }
} }
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> { suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> {
logMessage(logs, "'$keyword' 키워드로 Google 검색 시작...") logMessage(logs, Strings.logSearchStart(keyword))
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") } val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options) val currentDriver = BrowserManager.getChromeDriver(options)
val results = mutableListOf<SearchResult>() val results = mutableListOf<SearchResult>()
@ -39,27 +42,49 @@ object ScrapingService {
if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url)) if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url))
} catch (e: Exception) { /* 개별 오류 무시 */ } } catch (e: Exception) { /* 개별 오류 무시 */ }
} }
logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.") logMessage(logs, Strings.logSearchSuccess(keyword, results.size))
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); BrowserManager.quitChromeDriver() logMessage(logs, Strings.logSearchError(e.message)); BrowserManager.quitChromeDriver()
} finally { if (!keepSession) BrowserManager.quitChromeDriver() } } finally { if (!keepSession) BrowserManager.quitChromeDriver() }
return results return results
} }
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? { // ⭐️ [수정] 함수 시그니처에 selectors 파라미터 추가
logMessage(logs, "URL 스크랩 시작: $url") suspend fun scrapeArticleByUrl(url: String, selectors: List<String>, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? {
logMessage(logs, Strings.logScrapeUrlStart(url))
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") } val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options) val currentDriver = BrowserManager.getChromeDriver(options)
return try { return try {
currentDriver.get(url) currentDriver.get(url)
Thread.sleep(2000) Thread.sleep(2000)
val doc = Jsoup.parse(currentDriver.pageSource) 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() // ⭐️ [수정] 인자로 받은 selectors 리스트를 쉼표로 연결하여 Jsoup 쿼리로 사용하고, 첫 번째 요소를 찾습니다.
if (articleContent.isBlank()) { logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다."); null } val articleElement = doc.select(selectors.joinToString(", "))?.firstOrNull()
else { logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)"); ScrapedData(sourceUrl = url, selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent) }
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) { } catch (e: Exception) {
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}"); BrowserManager.quitChromeDriver(); null logMessage(logs, Strings.logScrapeUrlError(e.message)); BrowserManager.quitChromeDriver(); null
} finally { if (!keepSession) BrowserManager.quitChromeDriver() } } finally {
if (!keepSession) BrowserManager.quitChromeDriver()
}
} }
} }

View File

@ -12,10 +12,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.ImageLoader import coil3.ImageLoader
import core.FileManager
import core.FileManager.loadScrapedJsonFiles
import core.FileManager.saveDataToJsonFile
import core.LlmApiService
import core.LlmApiService.analyzeReceipts import core.LlmApiService.analyzeReceipts
import core.LlmApiService.cleanupWorkspace import core.LlmApiService.cleanupWorkspace
import core.LlmApiService.generateBlogPost import core.LlmApiService.generateBlogPost
@ -34,56 +30,67 @@ import models.ScrapedData
import models.SearchResult import models.SearchResult
import ui.tabs.* import ui.tabs.*
import ui.widgets.FileDialog import ui.widgets.FileDialog
import utils.FileManager.loadScrapedJsonFiles
import utils.FileManager.saveDataToJsonFile
import utils.Global
import utils.Global.jsonParser import utils.Global.jsonParser
import utils.Strings
import utils.logMessage import utils.logMessage
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.io.File import java.io.File
import java.util.prefs.Preferences import java.util.prefs.Preferences
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.text.isBlank
// (기존 App Composable의 상태 변수 선언 및 로직을 여기에 모두 이동)
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java) private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
const val PREF_FOLDER_PATH = "folder_path" const val PREF_FOLDER_PATH = Global.PREF_FOLDER_PATH
const val PREF_API_KEY = "api_key" const val PREF_API_KEY = Global.PREF_API_KEY
const val PREF_WORKSPACE_SLUG = "workspace_slug" const val PREF_WORKSPACE_SLUG = Global.PREF_WORKSPACE_SLUG
const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" const val PREF_RECEIPT_WORKSPACE_SLUG = Global.PREF_RECEIPT_WORKSPACE_SLUG
const val PREF_MODEL_NAME = "model_name" 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 @Composable
fun App(imageLoader: ImageLoader) { fun App(imageLoader: ImageLoader) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var tabIndex by remember { mutableStateOf(0) } var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("스크랩 기반 포스팅", "직접 포스팅", "영수증 분석기", "통신 로그", "블로그 결과") val tabs = Strings.TABS + "설정"
var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
var blogPostFooter by remember { mutableStateOf("") }
val logMessages = remember { mutableStateListOf<String>() } val logMessages = remember { mutableStateListOf<String>() }
var isLoading by remember { mutableStateOf(false) } 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<List<String>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
var selectedKeyword by remember { mutableStateOf("") } 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 isBrowserVisible by remember { mutableStateOf(true) }
var keepBrowserSession 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 apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my-workspace")) } 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, "receipts")) } 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, "Llama-3.1-8B-Vision")) } var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, Strings.DEFAULT_MODEL_NAME)) }
var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) } var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) } var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
var currentlyOpenFile by remember { mutableStateOf<File?>(null) } var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") } var viewedFileContent by remember { mutableStateOf(Strings.PLACEHOLDER_FILE_CONTENT) }
var imagesForSelection by remember { mutableStateOf<List<String>>(emptyList()) } var combinedImagesFromSelectedFiles by remember { mutableStateOf<List<String>>(emptyList()) }
var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) } var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) }
var manualKeyword by remember { mutableStateOf("") } 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 isImageUploadDialogVisible by remember { mutableStateOf(false) }
var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) } var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptFiles by remember { mutableStateOf<List<File>>(emptyList()) } var receiptFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") } var receiptAnalysisResult by remember { mutableStateOf(Strings.PLACEHOLDER_RECEIPT_ANALYSIS) }
var isReceiptDialogVisible by remember { mutableStateOf(false) } var isReceiptDialogVisible by remember { mutableStateOf(false) }
var analysisJob by remember { mutableStateOf<Job?>(null) } var analysisJob by remember { mutableStateOf<Job?>(null) }
var receiptContextPrompt by remember { mutableStateOf("") } var receiptContextPrompt by remember { mutableStateOf("") }
@ -91,79 +98,186 @@ fun App(imageLoader: ImageLoader) {
var userMainTopic by remember { mutableStateOf("") } var userMainTopic by remember { mutableStateOf("") }
var revisionRequest 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(apiKey) { prefs.put(PREF_API_KEY, apiKey) }
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) } LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) } LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) }
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) } 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<String>()
val allPreSelectedImages = mutableSetOf<String>()
selectedFiles.forEach { file ->
try {
val data = jsonParser.decodeFromString<ScrapedData>(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 { MaterialTheme {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// --- 상단 설정 UI ---
Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) { 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)) } 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("스크랩 저장 폴더 경로") }, 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)) Spacer(Modifier.height(8.dp)); OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text(Strings.LABEL_SCRAP_FOLDER_PATH) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
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 = apiKey, onValueChange = { apiKey = it }, label = { Text(Strings.PLACEHOLDER_API_KEY) }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("사용 중인 LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true) 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() 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) { when (tabIndex) {
0 -> ScrapBasedPostTab( 0 -> ScrapBasedPostTab(
isLoading, keywords, searchResults, scrapedFiles, selectedFiles, viewedFileContent, imagesForSelection, isLoading, keywords, searchResults, scrapedFiles, selectedFiles, viewedFileContent,
combinedImagesFromSelectedFiles,
currentSelectedImages, userPrompt, imageLoader, manualKeyword, userScrapComment, userMainTopic, currentSelectedImages, userPrompt, imageLoader, manualKeyword, userScrapComment, userMainTopic,
onManualKeywordChange = { manualKeyword = it }, onUserPromptChange = { userPrompt = it }, onUserScrapCommentChange = { userScrapComment = it }, onUserMainTopicChange = { userMainTopic = it }, 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 } }, 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 } }, 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) } }, onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages) } },
onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file }, onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file },
onFileView = { file -> coroutineScope.launch(Dispatchers.IO) { try { val data = jsonParser.decodeFromString<ScrapedData>(file.readText()); currentlyOpenFile = file; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") } } }, onFileView = { file ->
coroutineScope.launch(Dispatchers.IO) {
try {
val data = jsonParser.decodeFromString<ScrapedData>(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 }, onImageSelect = { imageUrl -> currentSelectedImages = if (imageUrl in currentSelectedImages) currentSelectedImages - imageUrl else currentSelectedImages + imageUrl },
onSaveChanges = { coroutineScope.launch(Dispatchers.IO) { currentlyOpenFile?.let { file -> try { val originalData = jsonParser.decodeFromString<ScrapedData>(file.readText()); val updatedData = originalData.copy(selectedImageUrls = currentSelectedImages.toList()); file.writeText(jsonParser.encodeToString(updatedData)); logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.") } catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") } } } }, 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<ScrapedData>(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 = { onGeneratePost = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@ScrapBasedPostTab } if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@ScrapBasedPostTab }
if (selectedFiles.isEmpty()) { logMessage(logMessages, "⚠️ 글을 생성할 스크랩 파일을 1개 이상 선택해주세요."); return@ScrapBasedPostTab } if (selectedFiles.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_NO_FILES_SELECTED); return@ScrapBasedPostTab }
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
try { try {
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) } val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
val allImages = selectedDataList.flatMap { it.selectedImageUrls }.distinct() val allImages = currentSelectedImages.toList()
val uploadedDocIds = uploadFiles(selectedFiles.toList(), apiKey, workspaceSlug,logMessages) val uploadedDocIds = uploadFiles(selectedFiles.toList(), apiKey, workspaceSlug, logMessages)
if (uploadedDocIds.isEmpty()) { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다."); return@launch } 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 { val footer = buildString {
appendLine("\n\n---") appendLine(Strings.Footer.SEPARATOR)
if (selectedDataList.isNotEmpty()) { if (selectedDataList.isNotEmpty()) {
appendLine("- 원문 출처:") appendLine(Strings.Footer.ORIGINAL_SOURCE_TITLE)
selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") } selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") }
} }
if (allImages.isNotEmpty()) { if (allImages.isNotEmpty()) {
appendLine("\n- 사용된 이미지:") appendLine(Strings.Footer.USED_IMAGES_TITLE)
allImages.forEach { appendLine(" - $it") } allImages.forEach { appendLine(" - $it") }
} }
appendLine(Strings.Footer.PROCESS_TITLE)
appendLine("\n\n[이 글의 작성 과정]")
val contextSummary = mutableListOf<String>() val contextSummary = mutableListOf<String>()
if (userMainTopic.isNotBlank()) contextSummary.add("주제: '${userMainTopic}'") if (userMainTopic.isNotBlank()) contextSummary.add(Strings.Footer.contextTopic(userMainTopic))
if (userScrapComment.isNotBlank()) contextSummary.add("작성자 코멘트: '${userScrapComment.take(30)}...'") if (userScrapComment.isNotBlank()) contextSummary.add(Strings.Footer.contextComment(userScrapComment))
if (userPrompt.isNotBlank()) contextSummary.add("요청 스타일: '${userPrompt.take(30)}...'") if (userPrompt.isNotBlank()) contextSummary.add(Strings.Footer.contextStyle(userPrompt))
val contextText = if (contextSummary.isNotEmpty()) "${contextSummary.joinToString(", ")} " else ""
append("이 포스팅은 ") append(Strings.Footer.scrapBased(contextText, modelName))
if (contextSummary.isNotEmpty()) append("${contextSummary.joinToString(", ")} 등의 정보를 바탕으로, ")
append("여러 참고 자료를 종합하여 ${modelName} AI 모델이 초안을 생성했습니다. 이후 작성자의 검토를 거쳐 수정 및 발행되었습니다.")
} }
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 blogPostResult = resultText
blogPostFooter = footer blogPostFooter = footer
tabIndex = 4 tabIndex = tabs.indexOf("블로그 결과")
cleanupWorkspace(uploadedDocIds, apiKey,logMessages) cleanupWorkspace(uploadedDocIds, apiKey, logMessages)
} finally { isLoading = false } } finally { isLoading = false }
} }
} }
@ -179,31 +293,58 @@ fun App(imageLoader: ImageLoader) {
onUploadImage = { isImageUploadDialogVisible = true }, onUploadImage = { isImageUploadDialogVisible = true },
onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove }, onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove },
onGeneratePost = { onGeneratePost = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@DirectPostTab } if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@DirectPostTab }
if (userOwnContent.isBlank() || uploadedImageFiles.isEmpty()) { logMessage(logMessages, "⚠️ 직접 포스팅을 하려면 내용과 이미지가 모두 필요합니다."); return@DirectPostTab } if (userOwnContent.isBlank() && uploadedImageFiles.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_DIRECT_POST_EMPTY); return@DirectPostTab }
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
try { try {
val allImages = uploadedImageFiles.map { it.toURI().toString() } 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 { val footer = buildString {
appendLine("\n\n---") appendLine(Strings.Footer.SEPARATOR)
if (allImages.isNotEmpty()) { if (allImages.isNotEmpty()) {
appendLine("- 사용된 이미지:") appendLine(Strings.Footer.USED_IMAGES_TITLE)
allImages.forEach { appendLine(" - $it") } allImages.forEach { appendLine(" - $it") }
} }
appendLine(Strings.Footer.PROCESS_TITLE)
appendLine("\n\n[이 글의 작성 과정]") val postscript = if (userPrompt.isNotBlank()) Strings.Footer.directPost(userPrompt, modelName)
append("이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ") else Strings.Footer.directPostNoPrompt(modelName)
if (userPrompt.isNotBlank()) append("'${userPrompt.take(30)}...' 스타일로 문체와 구성을 다듬도록 요청하여, ") append(postscript)
append("${modelName} AI 모델의 도움을 받아 완성되었습니다.")
} }
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 blogPostResult = resultText
blogPostFooter = footer blogPostFooter = footer
tabIndex = 4 tabIndex = tabs.indexOf("블로그 결과")
} finally { isLoading = false } } finally { isLoading = false }
} }
} }
@ -220,10 +361,10 @@ fun App(imageLoader: ImageLoader) {
isLoading = true isLoading = true
val resultFlow = MutableStateFlow("") val resultFlow = MutableStateFlow("")
val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } } 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() uiUpdateJob.cancel()
} catch (e: CancellationException) { logMessage(logMessages, " 영수증 분석이 사용자에 의해 중단되었습니다."); receiptAnalysisResult = "분석이 중단되었습니다." } catch (e: CancellationException) { logMessage(logMessages, Strings.LOG_INFO_ANALYSIS_CANCELLED); receiptAnalysisResult = Strings.ANALYSIS_STOPPED
} catch (e: Exception) { logMessage(logMessages, "❌ 영수증 분석 중 심각한 오류 발생: ${e.message}"); receiptAnalysisResult = "오류 발생: ${e.message}" } catch (e: Exception) { logMessage(logMessages, Strings.logAnalysisError(e.message)); receiptAnalysisResult = Strings.analysisError(e.message)
} finally { isLoading = false; analysisJob = null } } finally { isLoading = false; analysisJob = null }
} }
}, },
@ -237,14 +378,11 @@ fun App(imageLoader: ImageLoader) {
onRevisionRequestChange = { revisionRequest = it }, onRevisionRequestChange = { revisionRequest = it },
isLoading = isLoading, isLoading = isLoading,
onRevise = { onRevise = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) { if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@ResultTab }
logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요.")
return@ResultTab
}
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
try { try {
val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug,logMessages) val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug, revisePromptPrefix, logMessages)
blogPostResult = revisedText blogPostResult = revisedText
revisionRequest = "" revisionRequest = ""
} finally { } finally {
@ -253,11 +391,40 @@ fun App(imageLoader: ImageLoader) {
} }
}, },
onCopyToClipboard = { onCopyToClipboard = {
val fullContent = blogPostResult + blogPostFooter val fullContent = blogPostResult + blogPostFooter + generationContextForClipboard
val stringSelection = StringSelection(fullContent) val stringSelection = StringSelection(fullContent)
val clipboard = Toolkit.getDefaultToolkit().systemClipboard Toolkit.getDefaultToolkit().systemClipboard.setContents(stringSelection, null)
clipboard.setContents(stringSelection, null) logMessage(logMessages, Strings.LOG_CLIPBOARD_COPY_SUCCESS)
logMessage(logMessages, "✅ 블로그 전체 내용(꼬리말 포함)이 클립보드에 복사되었습니다.") }
)
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, " 프롬프트 및 설정을 기본값으로 초기화했습니다.")
} }
) )
} }

View File

@ -1,4 +1,4 @@
// ui/tabs/ScrapBasedPostTab.kt // ui/tabs/tabs.kt
package ui.tabs package ui.tabs
import androidx.compose.foundation.Image 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.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
// (필요한 import 추가)
import androidx.compose.material.* 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -20,15 +22,17 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.rememberAsyncImagePainter import coil3.compose.rememberAsyncImagePainter
import io.ktor.client.request.url
import models.SearchResult import models.SearchResult
import utils.Strings
import java.io.File import java.io.File
@Composable @Composable
fun ScrapBasedPostTab( fun ScrapBasedPostTab(
isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>, scrapedFiles: List<File>, selectedFiles: Set<File>, isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>, scrapedFiles: List<File>, selectedFiles: Set<File>,
viewedFileContent: String, imagesForSelection: List<String>, currentSelectedImages: Set<String>, viewedFileContent: String,
combinedImagesFromSelectedFiles: List<String>,
currentSelectedImages: Set<String>,
userPrompt: String, imageLoader: ImageLoader, manualKeyword: String, userScrapComment: String, userMainTopic: String, userPrompt: String, imageLoader: ImageLoader, manualKeyword: String, userScrapComment: String, userMainTopic: String,
onManualKeywordChange: (String) -> Unit, onUserPromptChange: (String) -> Unit, onUserScrapCommentChange: (String) -> Unit, onUserMainTopicChange: (String) -> Unit, onManualKeywordChange: (String) -> Unit, onUserPromptChange: (String) -> Unit, onUserScrapCommentChange: (String) -> Unit, onUserMainTopicChange: (String) -> Unit,
onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit, onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit,
@ -40,12 +44,12 @@ fun ScrapBasedPostTab(
// 1. 키워드 및 검색 // 1. 키워드 및 검색
Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) { Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) {
Row(modifier = Modifier.fillMaxWidth()) { 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)) 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)) 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)) Divider(modifier = Modifier.padding(vertical = 8.dp))
LazyColumn(modifier = Modifier.weight(1f)) { LazyColumn(modifier = Modifier.weight(1f)) {
items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) } items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) }
@ -53,7 +57,7 @@ fun ScrapBasedPostTab(
} }
// 2. 검색 결과 및 스크랩 // 2. 검색 결과 및 스크랩
Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(4.dp)) { 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()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(searchResults) { result -> items(searchResults) { result ->
Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) { 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)) { Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("저장된 파일", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp)) Text(Strings.TITLE_SAVED_FILES, style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") } Button(onClick = onRefreshFiles, enabled = !isLoading) { Text(Strings.BUTTON_REFRESH) }
} }
Box(modifier = Modifier.heightIn(max = 200.dp)) { Box(modifier = Modifier.heightIn(max = 200.dp)) {
LazyColumn { LazyColumn {
@ -82,15 +86,15 @@ fun ScrapBasedPostTab(
} }
} }
Spacer(Modifier.height(8.dp)) 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) 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)) Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("대표 이미지 선택 (다중 가능)", style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f)) Text(Strings.TITLE_SELECT_IMAGES, style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f))
Button(onClick = onSaveChanges, enabled = !isLoading && imagesForSelection.isNotEmpty()) { Text("선택 이미지 저장") } 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)) { LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) {
items(imagesForSelection) { imageUrl -> items(combinedImagesFromSelectedFiles) { imageUrl ->
val isSelected = imageUrl in currentSelectedImages val isSelected = imageUrl in currentSelectedImages
Box(modifier = Modifier.padding(4.dp)) { 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) 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)) { Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(8.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(100.dp), label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") }) OutlinedTextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_PROMPT) })
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = userMainTopic, value = userMainTopic,
onValueChange = onUserMainTopicChange, onValueChange = onUserMainTopicChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("글의 핵심 주제 (예: 2025년 최신 IT 트렌드)") }, label = { Text(Strings.LABEL_MAIN_TOPIC) },
singleLine = true singleLine = true
) )
Spacer(Modifier.height(8.dp)) 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)) Spacer(Modifier.height(8.dp))
Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { 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)) { Row(modifier = Modifier.fillMaxSize().padding(8.dp)) {
// 1. 내용 작성 // 1. 내용 작성
Column(modifier = Modifier.weight(2f).padding(end = 8.dp)) { 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( OutlinedTextField(
value = userOwnContent, value = userOwnContent,
onValueChange = onUserOwnContentChange, onValueChange = onUserOwnContentChange,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
label = { Text("블로그에 올릴 내용을 직접 작성하세요.") } label = { Text(Strings.LABEL_DIRECT_POST_CONTENT) }
) )
} }
// 2. 이미지 및 생성 제어 // 2. 이미지 및 생성 제어
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(8.dp)) { Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(8.dp)) {
Text("이미지 업로드", style = MaterialTheme.typography.h6) Text(Strings.TITLE_IMAGE_UPLOAD, style = MaterialTheme.typography.h6)
Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("내 PC에서 이미지 업로드") } Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_IMAGE) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) {
items(uploadedImageFiles) { file -> items(uploadedImageFiles) { file ->
@ -173,19 +177,19 @@ fun DirectPostTab(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("LLM 요청사항", style = MaterialTheme.typography.h6) Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6)
OutlinedTextField( OutlinedTextField(
value = userPrompt, value = userPrompt,
onValueChange = onUserPromptChange, onValueChange = onUserPromptChange,
modifier = Modifier.fillMaxWidth().height(150.dp), modifier = Modifier.fillMaxWidth().height(150.dp),
label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") } label = { Text(Strings.LABEL_USER_PROMPT) }
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Button( Button(
onClick = onGeneratePost, onClick = onGeneratePost,
enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(), enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { Text("작성한 내용으로 글 생성") } ) { Text(Strings.BUTTON_GENERATE_POST_DIRECT) }
} }
} }
if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
@ -209,8 +213,8 @@ fun ReceiptAnalyzerTab(
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("영수증 분석기", style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 8.dp)) Text(Strings.TITLE_RECEIPT_ANALYZER, style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 8.dp))
Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("영수증 이미지 업로드") } Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_RECEIPT) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) {
items(receiptFiles) { file -> items(receiptFiles) { file ->
@ -229,8 +233,8 @@ fun ReceiptAnalyzerTab(
value = receiptContextPrompt, value = receiptContextPrompt,
onValueChange = onReceiptContextPromptChange, onValueChange = onReceiptContextPromptChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { Text("추가 정보를 입력하면 더 정확하게 분석할 수 있습니다.") }, placeholder = { Text(Strings.PLACEHOLDER_RECEIPT_CONTEXT) },
label = { Text("추가 정보 입력 (예: 부산 출장 경비)") } label = { Text(Strings.LABEL_RECEIPT_CONTEXT) }
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (isAnalyzing) { if (isAnalyzing) {
@ -238,12 +242,12 @@ fun ReceiptAnalyzerTab(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("분석 중단하기") Text(Strings.BUTTON_CANCEL_ANALYSIS)
} }
} }
} else { } else {
Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
Text("선택한 영수증 분석 시작 (${receiptFiles.size}개)") Text(Strings.buttonStartAnalysis(receiptFiles.size))
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@ -252,7 +256,7 @@ fun ReceiptAnalyzerTab(
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
label = { Text("분석 결과 (내용 복사하여 사용)") } label = { Text(Strings.LABEL_ANALYSIS_RESULT) }
) )
} }
if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
@ -281,15 +285,15 @@ fun ResultTab(
value = result, value = result,
onValueChange = onRequestResultChange, onValueChange = onRequestResultChange,
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
label = { Text("블로그 글 결과 (LLM 생성 본문)") } label = { Text(Strings.LABEL_BLOG_RESULT) }
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = revisionRequest, value = revisionRequest,
onValueChange = onRevisionRequestChange, onValueChange = onRevisionRequestChange,
modifier = Modifier.fillMaxWidth().height(100.dp), modifier = Modifier.fillMaxWidth().height(100.dp),
label = { Text("추가 요청사항") }, label = { Text(Strings.LABEL_REVISION_REQUEST) },
placeholder = { Text("예: 문체를 좀 더 전문적으로 바꿔줘. 1번 항목을 더 자세히 설명해줘.") }, placeholder = { Text(Strings.PLACEHOLDER_REVISION_REQUEST) },
enabled = !isLoading enabled = !isLoading
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -302,7 +306,7 @@ fun ResultTab(
if (isLoading) { if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp)
} else { } else {
Text("LLM으로 글 보완하기") Text(Strings.BUTTON_REVISE_POST)
} }
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@ -311,9 +315,172 @@ fun ResultTab(
enabled = !isLoading && result.isNotBlank(), enabled = !isLoading && result.isNotBlank(),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("전체 내용 클립보드에 복사") Text(Strings.BUTTON_COPY_TO_CLIPBOARD)
} }
} }
} }
} }
} }
// ⭐️ [수정] SettingsTab 함수 시그니처 변경
@Composable
fun SettingsTab(
generatePromptPrefix: String,
onGeneratePromptPrefixChange: (String) -> Unit,
generatePromptInstructions: List<String>,
onGeneratePromptInstructionsChange: (List<String>) -> Unit,
revisePrompt: String,
onRevisePromptChange: (String) -> Unit,
receiptPrompt: String,
onReceiptPromptChange: (String) -> Unit,
articleSelectors: List<String>,
onArticleSelectorsChange: (List<String>) -> 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)) }
}
}

View File

@ -4,6 +4,7 @@ package ui.widgets
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import utils.Strings
import java.awt.FileDialog import java.awt.FileDialog
import java.awt.Frame import java.awt.Frame
import java.io.File import java.io.File
@ -11,7 +12,7 @@ import java.io.File
@Composable @Composable
fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List<File>) -> Unit) { fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List<File>) -> Unit) {
val fileDialog = remember { val fileDialog = remember {
FileDialog(parent, "파일 선택", FileDialog.LOAD).apply { FileDialog(parent, Strings.FILE_DIALOG_TITLE, FileDialog.LOAD).apply {
isMultipleMode = true isMultipleMode = true
} }
} }

View File

@ -6,7 +6,13 @@ import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.prefs.Preferences import java.util.prefs.Preferences
@ -25,9 +31,272 @@ object Global {
const val PREF_WORKSPACE_SLUG = "workspace_slug" const val PREF_WORKSPACE_SLUG = "workspace_slug"
const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
const val PREF_MODEL_NAME = "model_name" 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<String>, message: String) { fun logMessage(logs: MutableList<String>, message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
logs.add(0, "$timestamp: $message") logs.add(0, "$timestamp: $message")
}
object FileManager {
fun saveDataToJsonFile(keyword: String, data: ScrapedData, folderPath: String, logs: MutableList<String>): File {
val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() }
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName)
file.writeText(Global.jsonParser.encodeToString(data))
logMessage(logs, Strings.logSavingScrapData(file.path))
return file
}
fun loadScrapedJsonFiles(folderPath: String, logs: MutableList<String>): List<File> {
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<String>, // List<String>을 인자로 받음
contentSourcePromptPart: String,
commentPromptPart: String,
mainTopicPromptPart: String,
imagePromptPart: String
): String {
// 동적 요청사항과 사용자 정의 요청사항을 합쳐서 최종 리스트 생성
val finalInstructions = mutableListOf<String>()
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)}...'"
}
} }