This commit is contained in:
lunaticbum 2025-10-01 18:45:48 +09:00
parent 871741b764
commit e826522192
2 changed files with 172 additions and 229 deletions

View File

@ -17,6 +17,9 @@ kotlin {
repositories { repositories {
mavenCentral() mavenCentral()
maven {
url = uri("https://company/com/maven2")
}
google() google()
maven("https://maven.datlag.dev/snapshots") maven("https://maven.datlag.dev/snapshots")
} }
@ -25,6 +28,13 @@ dependencies {
// Jetpack Compose for Desktop UI // Jetpack Compose for Desktop UI
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
// ⭐️ [추가] 이미지 로딩 라이브러리 Coil
// ⭐️ [추가] 새로운 Coil 3 라이브러리
implementation("io.coil-kt.coil3:coil-compose-core:3.0.0-alpha06")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.0-alpha06")
// Coil 3의 OkHttp Fetcher는 OkHttp 라이브러리가 별도로 필요합니다.
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0")
// Ktor for HTTP Client (LLM API 호출용) // Ktor for HTTP Client (LLM API 호출용)
val ktorVersion = "2.3.12" val ktorVersion = "2.3.12"
implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("io.ktor:ktor-client-cio:$ktorVersion")

View File

@ -1,7 +1,9 @@
import androidx.compose.foundation.Image
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -10,10 +12,16 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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 androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
// ⭐️ [수정] Coil 3 import 경로 수정
import coil3.ImageLoader
//import coil3.compose.LocalImageLoader
import coil3.compose.rememberAsyncImagePainter
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
@ -39,52 +47,34 @@ 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
import kotlin.io.path.Path
import kotlin.io.path.readText
// --- 데이터 클래스 정의 --- // --- 데이터 클래스 정의 ---
@Serializable data class AnythingLLMChatRequest(val message: String) @Serializable data class AnythingLLMChatRequest(val message: String)
@Serializable data class AnythingLLMChatResponse(val textResponse: String) @Serializable data class AnythingLLMChatResponse(val textResponse: String)
@Serializable data class UploadResponse(val documents: List<Document>) { @Serializable data class Document(val id: String, val location: String) }
@Serializable @Serializable data class DeleteDocumentsRequest(val deletes: List<String>)
data class UploadResponse(val documents: List<Document>) {
@Serializable
data class Document(val id: String, val location: String)
}
@Serializable
data class DeleteDocumentsRequest(val deletes: List<String>)
data class SearchResult(val title: String, val url: String) data class SearchResult(val title: String, val url: String)
// ⭐️ [추가] 스크랩된 모든 정보를 담는 데이터 클래스 (JSON 저장용)
@Serializable @Serializable
data class ScrapedData( data class ScrapedData(
val sourceUrl: String, val sourceUrl: String,
val imageUrl: String?, val selectedImageUrl: String?,
val allImageUrls: List<String>,
val content: String val content: String
) )
// --- 전역 변수 및 헬퍼 --- // --- 전역 변수 및 헬퍼 ---
private val httpClient = HttpClient(CIO) { private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) }
json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) install(HttpTimeout) { requestTimeoutMillis = 300000 }
}
install(HttpTimeout) {
requestTimeoutMillis = 300000
}
} }
private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true } private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
private var driver: ChromeDriver? = null private var driver: ChromeDriver? = null
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java) private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
private const val PREF_FOLDER_PATH = "folder_path" private const val PREF_FOLDER_PATH = "folder_path"
private const val PREF_API_KEY = "api_key" private const val PREF_API_KEY = "api_key"
private const val PREF_WORKSPACE_SLUG = "workspace_slug" private const val PREF_WORKSPACE_SLUG = "workspace_slug"
// ⭐️ [추가] LLM 모델 이름 저장을 위한 키
private const val PREF_MODEL_NAME = "model_name" private const val PREF_MODEL_NAME = "model_name"
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")
@ -92,14 +82,8 @@ fun logMessage(logs: MutableList<String>, message: String) {
// --- 브라우저 관리 --- // --- 브라우저 관리 ---
private fun getChromeDriver(options: ChromeOptions): ChromeDriver { private fun getChromeDriver(options: ChromeOptions): ChromeDriver {
try { try { driver?.title } catch (e: WebDriverException) { driver = null }
driver?.title if (driver == null) { driver = ChromeDriver(options) }
} catch (e: WebDriverException) {
driver = null
}
if (driver == null) {
driver = ChromeDriver(options)
}
return driver!! return driver!!
} }
@ -108,23 +92,17 @@ private fun quitChromeDriver() {
driver = null driver = null
} }
// --- 핵심 기능 함수들 --- // --- 핵심 기능 함수들 (이하 로직은 동일) ---
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, "Google Trends 페이지 스크랩 시작...")
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 { val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
if (!isBrowserVisible) addArguments("--headless=new")
addArguments("--disable-gpu")
}
val currentDriver = getChromeDriver(options) val currentDriver = getChromeDriver(options)
val keywords = mutableListOf<String>()
return try { return try {
currentDriver.get(trendsUrl) currentDriver.get(trendsUrl)
Thread.sleep(2000) Thread.sleep(5000)
val elements = currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")) currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]"))
elements.mapNotNullTo(keywords) { it.text.takeIf { it.isNotBlank() } } .mapNotNull { it.text.takeIf(String::isNotBlank) }.also { logMessage(logs, "✅ Google Trends 키워드 ${it.size}개 스크랩 완료.") }
logMessage(logs, "✅ Google Trends 키워드 ${keywords.size}개 스크랩 완료.")
keywords
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}") logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}")
quitChromeDriver() quitChromeDriver()
@ -136,23 +114,18 @@ suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boole
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, "'$keyword' 키워드로 Google 검색 시작...")
val options = ChromeOptions().apply { val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
if (!isBrowserVisible) addArguments("--headless=new")
addArguments("--disable-gpu")
}
val currentDriver = getChromeDriver(options) val currentDriver = getChromeDriver(options)
val results = mutableListOf<SearchResult>() val results = mutableListOf<SearchResult>()
try { try {
currentDriver.get("https://www.google.com/search?q=$keyword") currentDriver.get("https://www.google.com/search?q=$keyword")
Thread.sleep(2000) Thread.sleep(10000)
val resultElements = currentDriver.findElements(By.cssSelector("div[data-rpos]")) currentDriver.findElements(By.cssSelector("div[data-rpos]")).take(10).forEach { element ->
for (element in resultElements.take(10)) {
try { try {
val title = element.findElement(By.cssSelector("h3")).text val title = element.findElement(By.cssSelector("h3")).text
val url = element.findElement(By.cssSelector("a")).getAttribute("href") val url = element.findElement(By.cssSelector("a")).getAttribute("href")
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, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.")
} catch (e: Exception) { } catch (e: Exception) {
@ -164,30 +137,29 @@ suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowser
return results return results
} }
// ⭐️ [수정] 반환 타입을 ScrapedData로 변경하고 이미지 URL 추출 로직 추가
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? { suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? {
logMessage(logs, "URL 스크랩 시작: $url") logMessage(logs, "URL 스크랩 시작: $url")
val options = ChromeOptions().apply { val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
if (!isBrowserVisible) addArguments("--headless=new")
addArguments("--disable-gpu")
}
val currentDriver = getChromeDriver(options) val currentDriver = 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("article, .article-body, #article_body, .news-article-body-view").text() val articleContent = doc.select("article, .article-body, #article_body, .news-article-body-view").text()
// OG 태그에서 대표 이미지 URL 추출 val allImages = doc.select("article img, .article-body img, #article_body img, .news-article-body-view img")
val imageUrl = doc.select("meta[property=og:image]").attr("content") .map { it.absUrl("src") }
.filter { it.isNotBlank() && it.startsWith("http") }
.distinct()
if (articleContent.isBlank()) { if (articleContent.isBlank()) {
logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.") logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.")
null null
} else { } else {
logMessage(logs, "✅ URL 스크랩 완료. (이미지: ${if (imageUrl.isNotBlank()) "있음" else "없음"})") logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)")
ScrapedData( ScrapedData(
sourceUrl = url, sourceUrl = url,
imageUrl = imageUrl.ifBlank { null }, selectedImageUrl = allImages.firstOrNull(),
allImageUrls = allImages,
content = articleContent content = articleContent
) )
} }
@ -200,65 +172,43 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowser
} }
} }
suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> { suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> {
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...") logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
val uploadedDocIds = mutableListOf<String>() val uploadedDocIds = mutableListOf<String>()
for (file in files) { for (file in files) {
logMessage(logs, " - '${file.name}' 업로드 중...") logMessage(logs, " - '${file.name}' 업로드 중...")
try { try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/upload") { val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/upload") {
header("Authorization", "Bearer $apiKey") header("Authorization", "Bearer $apiKey")
setBody(MultiPartFormDataContent( setBody(MultiPartFormDataContent(formData {
formData { append("addToWorkspaces", workspaceSlug)
append("addToWorkspaces", workspaceSlug) val scrapedData = jsonParser.decodeFromString<ScrapedData>(file.readText())
// ⭐️ [수정] JSON 파일의 내용을 텍스트로 읽어 전송 append("file", scrapedData.content.toByteArray(), Headers.build {
val scrapedData = jsonParser.decodeFromString<ScrapedData>(file.readText()) append(HttpHeaders.ContentType, ContentType.Text.Plain)
append("file", scrapedData.content.toByteArray(), Headers.build { append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
append(HttpHeaders.ContentType, ContentType.Text.Plain) })
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") }))
})
}
))
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val uploadResponse = response.body<UploadResponse>() val uploadResponse = response.body<UploadResponse>()
val docId = uploadResponse.documents.firstOrNull()?.id uploadResponse.documents.firstOrNull()?.id?.let {
if (docId != null) { uploadedDocIds.add(it)
uploadedDocIds.add(docId) logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).")
logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $docId).") } ?: logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
} else { } else { logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}") }
logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.") } catch (e: Exception) { logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}") }
}
} else {
logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}")
}
} catch (e: Exception) {
logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}")
}
} }
logMessage(logs, "${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.") logMessage(logs, "${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.")
return uploadedDocIds return uploadedDocIds
} }
// ⭐️ [수정] ScrapedData 리스트와 modelName을 파라미터로 받도록 변경 suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, userDirection: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, userDirection: String, modelName: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
logMessage(logs, "LLM 블로그 글 생성 요청...") logMessage(logs, "LLM 블로그 글 생성 요청...")
val chosenImage = scrapedDataList.firstNotNullOfOrNull { it.selectedImageUrl }
// ⭐️ [수정] LLM에게 전달할 참고자료 형식 변경
val referencesText = scrapedDataList.mapIndexed { index, data -> val referencesText = scrapedDataList.mapIndexed { index, data ->
""" "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n"
[참고자료 ${index + 1}] }.joinToString("\n")
- 원문 출처: ${data.sourceUrl}
- 대표 이미지: ${data.imageUrl ?: "없음"}
- 내용 요약: ${data.content.take(500)}...
""".trimIndent()
}.joinToString("\n\n")
// ⭐️ [수정] 최종 프롬프트 수정
val finalPrompt = """ val finalPrompt = """
당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다.
아래 '참고자료' '요청사항' 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. 아래 '참고자료' '요청사항' 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요.
@ -270,15 +220,8 @@ suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, use
1. 사용자 요청: "$userDirection" 1. 사용자 요청: "$userDirection"
2. 참고자료의 내용을 종합적으로 활용하여 하나의 완성된 글을 작성해주세요. 2. 참고자료의 내용을 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3 추천해주세요. 3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3 추천해주세요.
4. 대표 이미지가 있는 경우, 글의 적절한 위치에 마크다운 형식 `![대표 이미지](${scrapedDataList.firstNotNullOfOrNull { it.imageUrl }})`으로 이미지를 삽입해주세요. ${if (chosenImage != null) "4. 글의 적절한 위치에 마크다운 형식 `![대표 이미지]($chosenImage)`으로 이미지를 삽입해주세요." else ""}
5. 글의 마지막에는 아래 형식에 맞춰 출처 정보를 반드시 포함해주세요. 5. 마지막에 출처에 대한 언급은 절대 하지 마세요.
---
- 원문 출처:
${scrapedDataList.map { "- ${it.sourceUrl}" }.joinToString("\n")}
- 이미지 출처: ${scrapedDataList.firstNotNullOfOrNull { it.imageUrl } ?: "없음"}
- 글은 ${modelName} 모델을 활용하여 작성되었습니다.
""".trimIndent() """.trimIndent()
val requestBody = AnythingLLMChatRequest(message = finalPrompt) val requestBody = AnythingLLMChatRequest(message = finalPrompt)
@ -288,15 +231,11 @@ suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, use
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(requestBody) setBody(requestBody)
} }
val responseBodyText = response.bodyAsText() val responseBodyText = response.bodyAsText()
logMessage(logs, "LLM 응답 수신: $responseBodyText") logMessage(logs, "LLM 응답 수신...")
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(responseBodyText) val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(responseBodyText)
logMessage(logs, "✅ LLM 블로그 글 생성 완료.") logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
return chatResponse.textResponse return chatResponse.textResponse
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}") logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}")
return "블로그 글 생성 실패: ${e.message}" return "블로그 글 생성 실패: ${e.message}"
@ -312,47 +251,31 @@ suspend fun cleanupLLMWorkspace(docIds: List<String>, logs: MutableList<String>,
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(DeleteDocumentsRequest(deletes = docIds)) setBody(DeleteDocumentsRequest(deletes = docIds))
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) { logMessage(logs, "✅ LLM 워크스페이스 정리 완료.") }
logMessage(logs, "✅ LLM 워크스페이스 정리 완료.") else { logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}") }
} else { } catch (e: Exception) { logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}") }
logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}")
}
} catch (e: Exception) {
logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}")
}
} }
// ⭐️ [수정] ScrapedData를 JSON 파일로 저장하는 함수 fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList<String>, folderPath: String): File {
fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList<String>, folderPath: String) { val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() }
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName)
try { try {
val directory = File(folderPath)
if (!directory.exists()) directory.mkdirs()
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
// ⭐️ [수정] 확장자를 .json으로 변경
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName)
file.writeText(jsonParser.encodeToString(data)) file.writeText(jsonParser.encodeToString(data))
logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.") logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.")
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ 파일 저장 중 오류: ${e.message}") logMessage(logs, "❌ 파일 저장 중 오류: ${e.message}")
} }
return file
} }
// ⭐️ [수정] .json 파일을 읽어오는 함수
fun loadScrapedJsonFiles(logs: MutableList<String>, folderPath: String): List<File> { fun loadScrapedJsonFiles(logs: MutableList<String>, folderPath: String): List<File> {
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...") logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
return try { return try {
val directory = File(folderPath) File(folderPath).listFiles { _, name -> name.endsWith(".json") }
if (!directory.exists() || !directory.isDirectory) {
logMessage(logs, "⚠️ '$folderPath' 폴더를 찾을 수 없습니다.")
return emptyList()
}
// ⭐️ [수정] .json 파일만 대상으로 함
val files = directory.listFiles { _, name -> name.endsWith(".json") }
?.sortedByDescending { it.lastModified() } ?.sortedByDescending { it.lastModified() }
?: emptyList() .orEmpty().also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") }
logMessage(logs, "✅ 파일 ${files.size}개 로딩 완료.")
files
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ 파일 로딩 중 오류: ${e.message}") logMessage(logs, "❌ 파일 로딩 중 오류: ${e.message}")
emptyList() emptyList()
@ -361,10 +284,10 @@ fun loadScrapedJsonFiles(logs: MutableList<String>, folderPath: String): List<Fi
// --- UI 컴포넌트 --- // --- UI 컴포넌트 ---
@Composable @Composable
fun App() { fun App(imageLoader: ImageLoader) {
val coroutineScope = rememberCoroutineScope()
var tabIndex by remember { mutableStateOf(0) } var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("워크플로우", "통신 로그", "블로그 결과") val tabs = listOf("워크플로우", "통신 로그", "블로그 결과")
val coroutineScope = rememberCoroutineScope()
var keywords by remember { mutableStateOf<List<String>>(emptyList()) } var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) } var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
@ -379,20 +302,20 @@ fun App() {
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) } var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) }
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")) } var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my")) }
// ⭐️ [추가] 모델 이름 상태 변수
var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "gpt-4o")) } var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "gpt-4o")) }
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 viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
LaunchedEffect(scrapedFolderPath) { var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
} var imagesForSelection by remember { mutableStateOf<List<String>>(emptyList()) }
var currentSelectedImage by remember { mutableStateOf<String?>(null) }
LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) }
LaunchedEffect(scrapedFolderPath) { prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) } LaunchedEffect(scrapedFolderPath) { 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(modelName) { prefs.put(PREF_MODEL_NAME, modelName) } LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
MaterialTheme { MaterialTheme {
@ -413,70 +336,58 @@ fun App() {
OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true) OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// ⭐️ [추가] 모델 이름 입력 필드
OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true) OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
} }
Divider() Divider()
TabRow(selectedTabIndex = tabIndex) { TabRow(selectedTabIndex = tabIndex) {
tabs.forEachIndexed { index, title -> tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) }
Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index })
}
} }
when (tabIndex) { when (tabIndex) {
0 -> WorkflowTab( 0 -> WorkflowTab(
isLoading = isLoading, keywords = keywords, searchResults = searchResults, isLoading = isLoading, keywords = keywords, searchResults = searchResults, scrapedFiles = scrapedFiles, selectedFiles = selectedFiles,
scrapedFiles = scrapedFiles, selectedFiles = selectedFiles, viewedFileContent = viewedFileContent, viewedFileContent = viewedFileContent, imagesForSelection = imagesForSelection, currentSelectedImage = currentSelectedImage,
userPrompt = userPrompt, onUserPromptChange = { userPrompt = it }, userPrompt = userPrompt, onUserPromptChange = { userPrompt = it }, imageLoader = imageLoader,
onFetchTrends = { onFetchTrends = { coroutineScope.launch(Dispatchers.IO) { isLoading = true; keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
coroutineScope.launch(Dispatchers.IO) { onKeywordSelect = { keyword -> selectedKeyword = keyword; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
isLoading = true
keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession)
isLoading = false
}
},
onKeywordSelect = { keyword ->
selectedKeyword = keyword
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession)
isLoading = false
}
},
onSearchResultSelect = { result -> onSearchResultSelect = { result ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
// ⭐️ [수정] ScrapedData를 받아 JSON으로 저장 scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)?.let { data ->
val data = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession) val savedFile = saveDataToJsonFile(selectedKeyword, data, logMessages, scrapedFolderPath)
if (data != null) {
saveDataToJsonFile(selectedKeyword, data, logMessages, scrapedFolderPath)
scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
currentlyOpenFile = savedFile
viewedFileContent = data.content
imagesForSelection = data.allImageUrls
currentSelectedImage = data.selectedImageUrl
} }
isLoading = false isLoading = false
} }
}, },
onRefreshFiles = { onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) } },
coroutineScope.launch(Dispatchers.IO) { onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file },
scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
}
},
onFileSelectToggle = { file, isSelected ->
selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file
},
onFileView = { file -> onFileView = { file ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
try { try {
// ⭐️ [수정] JSON을 읽어 본문 내용만 표시 val data = jsonParser.decodeFromString<ScrapedData>(file.readText())
val data = jsonParser.decodeFromString<ScrapedData>(Path(file.absolutePath).readText(Charsets.UTF_8)) currentlyOpenFile = file
viewedFileContent = """ viewedFileContent = data.content
[원문] ${data.sourceUrl} imagesForSelection = data.allImageUrls
[이미지] ${data.imageUrl ?: "없음"} currentSelectedImage = data.selectedImageUrl
--------------------
${data.content}
""".trimIndent()
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}")
viewedFileContent = "파일을 읽는 중 오류가 발생했습니다." }
}
},
onImageSelect = { imageUrl -> currentSelectedImage = imageUrl },
onSaveChanges = {
coroutineScope.launch(Dispatchers.IO) {
currentlyOpenFile?.let { file ->
try {
val originalData = jsonParser.decodeFromString<ScrapedData>(file.readText())
val updatedData = originalData.copy(selectedImageUrl = currentSelectedImage)
file.writeText(jsonParser.encodeToString(updatedData))
logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.")
} catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") }
} }
} }
}, },
@ -489,27 +400,27 @@ fun App() {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
var uploadedDocIds: List<String> = emptyList() var uploadedDocIds: List<String> = emptyList()
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
try { try {
// ⭐️ [수정] 선택된 JSON 파일들을 ScrapedData로 변환
val selectedData = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
uploadedDocIds = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug) uploadedDocIds = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug)
if (uploadedDocIds.isNotEmpty()) { if (uploadedDocIds.isNotEmpty()) {
// ⭐️ [수정] ScrapedData 리스트와 모델 이름을 전달 val resultText = generateBlogPostWithLocalLLM(selectedDataList, userPrompt, logMessages, apiKey, workspaceSlug)
blogPostResult = generateBlogPostWithLocalLLM(selectedData, userPrompt, modelName, logMessages, apiKey, workspaceSlug) val footer = buildString {
appendLine("\n\n---")
appendLine("- 원문 출처:")
selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") }
selectedDataList.firstNotNullOfOrNull { it.selectedImageUrl }?.let { appendLine("- 이미지 출처: $it") }
appendLine("- 이 글은 ${modelName} 모델을 활용하여 작성되었습니다.")
}
blogPostResult = resultText + footer
tabIndex = 2 tabIndex = 2
} else { } else { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.") }
logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.")
blogPostResult = "파일 업로드 실패"
}
} finally { } finally {
cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey) cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey)
isLoading = false isLoading = false
} }
} }
} else { } else { logMessage(logMessages, "⚠️ 블로그 글을 생성할 파일을 선택해주세요.") }
logMessage(logMessages, "⚠️ 블로그 글을 생성할 파일을 선택해주세요.")
}
} }
) )
1 -> LogTab(logMessages) 1 -> LogTab(logMessages)
@ -521,22 +432,17 @@ fun App() {
@Composable @Composable
fun WorkflowTab( fun WorkflowTab(
isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>, isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>, scrapedFiles: List<File>, selectedFiles: Set<File>,
scrapedFiles: List<File>, selectedFiles: Set<File>, viewedFileContent: String, viewedFileContent: String, imagesForSelection: List<String>, currentSelectedImage: String?, userPrompt: String,imageLoader: ImageLoader,
userPrompt: String, onUserPromptChange: (String) -> Unit, onFetchTrends: () -> Unit, onUserPromptChange: (String) -> Unit, onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit,
onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit, onSearchResultSelect: (SearchResult) -> Unit, onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit,
onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit, onFileView: (File) -> Unit, onImageSelect: (String?) -> Unit, onSaveChanges: () -> Unit, onGeneratePost: () -> Unit
onFileView: (File) -> Unit, onGeneratePost: () -> Unit
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(4.dp)) { Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(4.dp)) {
Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") } Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") }
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) { 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))
}
}
} }
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)) {
Text("검색 결과", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) Text("검색 결과", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
@ -565,7 +471,28 @@ fun WorkflowTab(
} }
Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(8.dp)) { Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(8.dp)) {
Text("파일 내용", style = MaterialTheme.typography.h6) Text("파일 내용", style = MaterialTheme.typography.h6)
Text(text = viewedFileContent, modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).border(1.dp, Color.LightGray).padding(4.dp), style = MaterialTheme.typography.body2) Text(text = viewedFileContent, modifier = Modifier.weight(0.5f).fillMaxWidth().verticalScroll(rememberScrollState()).border(1.dp, Color.LightGray).padding(4.dp), style = MaterialTheme.typography.body2)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("대표 이미지 선택", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f))
Button(onClick = onSaveChanges, enabled = !isLoading && imagesForSelection.isNotEmpty()) { Text("선택 이미지 저장") }
}
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray), userScrollEnabled = true) {
items(imagesForSelection) { imageUrl ->
val isSelected = imageUrl == currentSelectedImage
Box(modifier = Modifier.padding(4.dp)) {
Image(
painter = rememberAsyncImagePainter(
model = imageUrl,
imageLoader = imageLoader // 이 부분을 추가하세요.
),
contentDescription = "Scraped Image",
modifier = Modifier.size(100.dp).clickable { onImageSelect(imageUrl) }.border(if (isSelected) 4.dp else 0.dp, MaterialTheme.colors.primary),
contentScale = ContentScale.Crop
)
}
}
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("LLM 요청사항", style = MaterialTheme.typography.h6) Text("LLM 요청사항", style = MaterialTheme.typography.h6)
TextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), placeholder = { Text("예: 선택된 파일들을 종합해서...") }) TextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), placeholder = { Text("예: 선택된 파일들을 종합해서...") })
@ -593,16 +520,22 @@ fun ResultTab(result: String) {
} }
} }
// --- 애플리케이션 시작점 ---
fun main() = application { fun main() = application {
// ⭐️ [Coil 3 변경] 데스크톱용 ImageLoader 생성
val imageLoader = ImageLoader.Builder(coil3.PlatformContext.INSTANCE)
.components { add(OkHttpNetworkFetcherFactory()) }
.build()
Window( Window(
onCloseRequest = { onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() },
quitChromeDriver() title = "자동 블로그 포스팅 도우미 v4.3"
httpClient.close()
exitApplication()
},
title = "자동 블로그 포스팅 도우미 v4.0 (Final)"
) { ) {
App() // ❌ [삭제] CompositionLocalProvider는 더 이상 사용하지 않습니다.
// CompositionLocalProvider(LocalImageLoader provides imageLoader) {
// App()
// }
// ✅ [수정] App 함수에 imageLoader를 파라미터로 직접 전달합니다.
App(imageLoader)
} }
} }