Compare commits

...

2 Commits

Author SHA1 Message Date
871741b764 ... 2025-10-01 17:56:25 +09:00
b95aa050ad ... 2025-10-01 17:48:44 +09:00

View File

@ -10,207 +10,345 @@ 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.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
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.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* 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.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.openqa.selenium.By import org.openqa.selenium.By
import org.openqa.selenium.WebDriverException
import org.openqa.selenium.chrome.ChromeDriver import org.openqa.selenium.chrome.ChromeDriver
import org.openqa.selenium.chrome.ChromeOptions import org.openqa.selenium.chrome.ChromeOptions
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.prefs.Preferences
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.readText 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
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
data class ScrapedData(
val sourceUrl: String,
val imageUrl: String?,
val content: String
)
// --- 전역 변수 및 헬퍼 ---
private val httpClient = HttpClient(CIO) { private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { isLenient = true; ignoreUnknownKeys = true }) json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 300000
} }
} }
private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true }
private var driver: ChromeDriver? = null
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
private const val PREF_FOLDER_PATH = "folder_path"
private const val PREF_API_KEY = "api_key"
private const val PREF_WORKSPACE_SLUG = "workspace_slug"
// ⭐️ [추가] LLM 모델 이름 저장을 위한 키
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")
} }
// --- 핵심 기능 함수들 --- // --- 브라우저 관리 ---
private fun getChromeDriver(options: ChromeOptions): ChromeDriver {
try {
driver?.title
} catch (e: WebDriverException) {
driver = null
}
if (driver == null) {
driver = ChromeDriver(options)
}
return driver!!
}
suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean): List<String> { private fun quitChromeDriver() {
driver?.quit()
driver = null
}
// --- 핵심 기능 함수들 ---
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) { if (!isBrowserVisible) addArguments("--headless=new")
addArguments("--headless=new")
}
addArguments("--disable-gpu") addArguments("--disable-gpu")
} }
val driver = ChromeDriver(options) val currentDriver = getChromeDriver(options)
val keywords = mutableListOf<String>() val keywords = mutableListOf<String>()
return try { return try {
driver.get(trendsUrl) currentDriver.get(trendsUrl)
Thread.sleep(2000) Thread.sleep(2000)
val elements = driver.findElements(By.xpath("//tr[count(td)=7]/td[2]")) val elements = currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]"))
for (element in elements) { elements.mapNotNullTo(keywords) { it.text.takeIf { it.isNotBlank() } }
val keywordText = element.text
if (keywordText.isNotBlank()) {
keywords.add(keywordText)
}
}
logMessage(logs, "✅ Google Trends 키워드 ${keywords.size}개 스크랩 완료.") logMessage(logs, "✅ Google Trends 키워드 ${keywords.size}개 스크랩 완료.")
keywords keywords
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}") logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}")
e.printStackTrace() quitChromeDriver()
emptyList() emptyList()
} finally { } finally {
driver.quit() if (!keepSession) quitChromeDriver()
} }
} }
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: 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) { if (!isBrowserVisible) addArguments("--headless=new")
addArguments("--headless=new")
}
addArguments("--disable-gpu") addArguments("--disable-gpu")
} }
val driver = ChromeDriver(options) val currentDriver = getChromeDriver(options)
val results = mutableListOf<SearchResult>() val results = mutableListOf<SearchResult>()
try { try {
driver.get("https://www.google.com/search?q=$keyword") currentDriver.get("https://www.google.com/search?q=$keyword")
Thread.sleep(2000) Thread.sleep(2000)
val resultElements = driver.findElements(By.cssSelector("div[data-rpos]")) val resultElements = currentDriver.findElements(By.cssSelector("div[data-rpos]"))
for (element in resultElements.take(10)) { for (element in resultElements.take(10)) {
try { try {
val titleElement = element.findElement(By.cssSelector("h3")) val title = element.findElement(By.cssSelector("h3")).text
val linkElement = element.findElement(By.cssSelector("a")) val url = element.findElement(By.cssSelector("a")).getAttribute("href")
val title = titleElement.text if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url))
val url = linkElement.getAttribute("href") } catch (e: Exception) { /* 개별 오류 무시 */
if (title.isNotBlank() && url.isNotBlank()) {
results.add(SearchResult(title, url))
}
} catch (e: Exception) {
// 개별 검색 결과 파싱 오류는 무시
} }
} }
logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.") logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.")
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ Google 검색 중 오류: ${e.message}") logMessage(logs, "❌ Google 검색 중 오류: ${e.message}")
e.printStackTrace() quitChromeDriver()
} finally { } finally {
driver.quit() if (!keepSession) quitChromeDriver()
} }
return results return results
} }
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean): String { // ⭐️ [수정] 반환 타입을 ScrapedData로 변경하고 이미지 URL 추출 로직 추가
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) { if (!isBrowserVisible) addArguments("--headless=new")
addArguments("--headless=new")
}
addArguments("--disable-gpu") addArguments("--disable-gpu")
} }
val driver = ChromeDriver(options) val currentDriver = getChromeDriver(options)
return try { return try {
driver.get(url) currentDriver.get(url)
Thread.sleep(2000) Thread.sleep(2000)
val finalHtml = driver.pageSource val doc = Jsoup.parse(currentDriver.pageSource)
val doc = Jsoup.parse(finalHtml)
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 imageUrl = doc.select("meta[property=og:image]").attr("content")
if (articleContent.isBlank()) { if (articleContent.isBlank()) {
logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.") logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.")
"기사 본문을 찾을 수 없습니다." null
} else { } else {
logMessage(logs, "✅ URL 스크랩 완료. (총 ${articleContent.length}자)") logMessage(logs, "✅ URL 스크랩 완료. (이미지: ${if (imageUrl.isNotBlank()) "있음" else "없음"})")
articleContent ScrapedData(
sourceUrl = url,
imageUrl = imageUrl.ifBlank { null },
content = articleContent
)
} }
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}") logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}")
e.printStackTrace() quitChromeDriver()
"페이지 스크랩 중 오류 발생: ${e.message}" null
} finally { } finally {
driver.quit() if (!keepSession) quitChromeDriver()
} }
} }
suspend fun generateBlogPostWithLocalLLM(fileNames: List<String>, userDirection: String, logs: MutableList<String>): String {
logMessage(logs, "LLM 블로그 글 생성 요청 (${fileNames.size}개 파일 기반)...")
val apiKey = System.getenv("ANYTHINGLLM_API_KEY") ?: "YOUR_API_KEY_HERE"
val fileNamesString = fileNames.joinToString(", ")
suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> {
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
val uploadedDocIds = mutableListOf<String>()
for (file in files) {
logMessage(logs, " - '${file.name}' 업로드 중...")
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/upload") {
header("Authorization", "Bearer $apiKey")
setBody(MultiPartFormDataContent(
formData {
append("addToWorkspaces", workspaceSlug)
// ⭐️ [수정] JSON 파일의 내용을 텍스트로 읽어 전송
val scrapedData = jsonParser.decodeFromString<ScrapedData>(file.readText())
append("file", scrapedData.content.toByteArray(), Headers.build {
append(HttpHeaders.ContentType, ContentType.Text.Plain)
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
})
}
))
}
if (response.status.isSuccess()) {
val uploadResponse = response.body<UploadResponse>()
val docId = uploadResponse.documents.firstOrNull()?.id
if (docId != null) {
uploadedDocIds.add(docId)
logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $docId).")
} else {
logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
}
} else {
logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}")
}
} catch (e: Exception) {
logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}")
}
}
logMessage(logs, "${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.")
return uploadedDocIds
}
// ⭐️ [수정] ScrapedData 리스트와 modelName을 파라미터로 받도록 변경
suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, userDirection: String, modelName: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
logMessage(logs, "LLM 블로그 글 생성 요청...")
// ⭐️ [수정] LLM에게 전달할 참고자료 형식 변경
val referencesText = scrapedDataList.mapIndexed { index, data ->
"""
[참고자료 ${index + 1}]
- 원문 출처: ${data.sourceUrl}
- 대표 이미지: ${data.imageUrl ?: "없음"}
- 내용 요약: ${data.content.take(500)}...
""".trimIndent()
}.joinToString("\n\n")
// ⭐️ [수정] 최종 프롬프트 수정
val finalPrompt = """ val finalPrompt = """
지식 베이스에 있는 다음 문서들을 종합적으로 참고해서 아래 '요청사항' 맞춰 SEO에 최적화된 블로그 글을 작성해줘. 제목도 2~3 추천해줘. 당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다.
아래 '참고자료' '요청사항' 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요.
--- 참고 문서 ---
$fileNamesString --- 참고자료 ---
$referencesText
--- 요청사항 --- --- 요청사항 ---
$userDirection 1. 사용자 요청: "$userDirection"
2. 참고자료의 내용을 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3 추천해주세요.
4. 대표 이미지가 있는 경우, 글의 적절한 위치에 마크다운 형식 `![대표 이미지](${scrapedDataList.firstNotNullOfOrNull { it.imageUrl }})`으로 이미지를 삽입해주세요.
5. 글의 마지막에는 아래 형식에 맞춰 출처 정보를 반드시 포함해주세요.
---
- 원문 출처:
${scrapedDataList.map { "- ${it.sourceUrl}" }.joinToString("\n")}
- 이미지 출처: ${scrapedDataList.firstNotNullOfOrNull { it.imageUrl } ?: "없음"}
- 글은 ${modelName} 모델을 활용하여 작성되었습니다.
""".trimIndent() """.trimIndent()
val requestBody = AnythingLLMChatRequest(message = finalPrompt) val requestBody = AnythingLLMChatRequest(message = finalPrompt)
return try { try {
val response: AnythingLLMChatResponse = httpClient.post("http://localhost:3001/api/v1/workspace/my/chat") { val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
header("Authorization", "Bearer $apiKey") header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(requestBody) setBody(requestBody)
}.body() }
val responseBodyText = response.bodyAsText()
logMessage(logs, "LLM 응답 수신: $responseBodyText")
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(responseBodyText)
logMessage(logs, "✅ LLM 블로그 글 생성 완료.") logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
response.textResponse return chatResponse.textResponse
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}") logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}")
e.printStackTrace() return "블로그 글 생성 실패: ${e.message}"
"블로그 글 생성 실패: ${e.message}"
} }
} }
fun saveContentToFile(keyword: String, content: String, logs: MutableList<String>) { suspend fun cleanupLLMWorkspace(docIds: List<String>, logs: MutableList<String>, apiKey: String) {
if (docIds.isEmpty()) return
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...")
try { try {
val directory = File("/Users/jibumhan/autoblog_content") val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(DeleteDocumentsRequest(deletes = docIds))
}
if (response.status.isSuccess()) {
logMessage(logs, "✅ LLM 워크스페이스 정리 완료.")
} else {
logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}")
}
} catch (e: Exception) {
logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}")
}
}
// ⭐️ [수정] ScrapedData를 JSON 파일로 저장하는 함수
fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList<String>, folderPath: String) {
try {
val directory = File(folderPath)
if (!directory.exists()) directory.mkdirs() if (!directory.exists()) directory.mkdirs()
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "") val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.txt" // ⭐️ [수정] 확장자를 .json으로 변경
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName) val file = File(directory, fileName)
file.writeText(content) 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}")
e.printStackTrace()
} }
} }
fun loadScrapedFiles(logs: MutableList<String>): List<File> { // ⭐️ [수정] .json 파일을 읽어오는 함수
logMessage(logs, "스크랩된 파일 목록 로딩...") fun loadScrapedJsonFiles(logs: MutableList<String>, folderPath: String): List<File> {
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
return try { return try {
val directory = File("scraped_articles") val directory = File(folderPath)
if (!directory.exists() || !directory.isDirectory) { if (!directory.exists() || !directory.isDirectory) {
logMessage(logs, "⚠️ 'scraped_articles' 폴더를 찾을 수 없습니다.") logMessage(logs, "⚠️ '$folderPath' 폴더를 찾을 수 없습니다.")
return emptyList() return emptyList()
} }
val files = directory.listFiles { _, name -> name.endsWith(".txt") } // ⭐️ [수정] .json 파일만 대상으로 함
val files = directory.listFiles { _, name -> name.endsWith(".json") }
?.sortedByDescending { it.lastModified() } ?.sortedByDescending { it.lastModified() }
?: emptyList() ?: emptyList()
logMessage(logs, "✅ 파일 ${files.size}개 로딩 완료.") logMessage(logs, "✅ 파일 ${files.size}개 로딩 완료.")
@ -221,16 +359,13 @@ fun loadScrapedFiles(logs: MutableList<String>): List<File> {
} }
} }
// --- UI 컴포넌트 --- // --- UI 컴포넌트 ---
@Composable @Composable
fun App() { fun App() {
var tabIndex by remember { mutableStateOf(0) } var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("워크플로우", "통신 로그", "블로그 결과") val tabs = listOf("워크플로우", "통신 로그", "블로그 결과")
val coroutineScope = rememberCoroutineScope() 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()) }
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") } var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
@ -238,30 +373,48 @@ fun App() {
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var selectedKeyword by remember { mutableStateOf("") } var selectedKeyword by remember { mutableStateOf("") }
var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") } var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") }
var isBrowserVisible by remember { mutableStateOf(true) } // 브라우저 가시성 상태
// 파일 관리 상태 var isBrowserVisible by remember { mutableStateOf(true) }
var keepBrowserSession by remember { mutableStateOf(false) }
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) }
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my")) }
// ⭐️ [추가] 모델 이름 상태 변수
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("파일을 선택하면 내용이 여기에 표시됩니다.") } var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
// 앱 시작 시 파일 목록 로드 LaunchedEffect(scrapedFolderPath) {
LaunchedEffect(Unit) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
scrapedFiles = loadScrapedFiles(logMessages)
} }
LaunchedEffect(scrapedFolderPath) { prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) }
LaunchedEffect(apiKey) { prefs.put(PREF_API_KEY, apiKey) }
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
// ⭐️ [추가] 모델 이름 변경 시 자동 저장
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
MaterialTheme { MaterialTheme {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// --- 전역 설정 (체크박스) --- Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) {
Row( Row(verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it })
verticalAlignment = Alignment.CenterVertically Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f))
) { Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it })
Checkbox( Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f))
checked = isBrowserVisible, }
onCheckedChange = { isBrowserVisible = it } Spacer(Modifier.height(8.dp))
) OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }) Spacer(Modifier.height(4.dp))
Row(Modifier.fillMaxWidth()) {
OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.weight(1f), singleLine = true, visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.width(4.dp))
OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true)
}
Spacer(Modifier.height(4.dp))
// ⭐️ [추가] 모델 이름 입력 필드
OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
} }
Divider() Divider()
@ -270,21 +423,15 @@ fun App() {
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, isLoading = isLoading, keywords = keywords, searchResults = searchResults,
keywords = keywords, scrapedFiles = scrapedFiles, selectedFiles = selectedFiles, viewedFileContent = viewedFileContent,
searchResults = searchResults, userPrompt = userPrompt, onUserPromptChange = { userPrompt = it },
scrapedFiles = scrapedFiles,
selectedFiles = selectedFiles,
viewedFileContent = viewedFileContent,
userPrompt = userPrompt,
onUserPromptChange = { userPrompt = it },
onFetchTrends = { onFetchTrends = {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
keywords = fetchGoogleTrends(logMessages, isBrowserVisible) keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession)
isLoading = false isLoading = false
} }
}, },
@ -292,37 +439,41 @@ fun App() {
selectedKeyword = keyword selectedKeyword = keyword
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible) searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession)
isLoading = false isLoading = false
} }
}, },
onSearchResultSelect = { result -> onSearchResultSelect = { result ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
val content = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible) // ⭐️ [수정] ScrapedData를 받아 JSON으로 저장
if (!content.contains("오류 발생")) { val data = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)
saveContentToFile(selectedKeyword, content, logMessages) if (data != null) {
scrapedFiles = loadScrapedFiles(logMessages) // 저장 후 목록 새로고침 saveDataToJsonFile(selectedKeyword, data, logMessages, scrapedFolderPath)
scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
} }
isLoading = false isLoading = false
} }
}, },
onRefreshFiles = { onRefreshFiles = {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
scrapedFiles = loadScrapedFiles(logMessages) scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
} }
}, },
onFileSelectToggle = { file, isSelected -> onFileSelectToggle = { file, isSelected ->
selectedFiles = if (isSelected) { selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file
selectedFiles + file
} else {
selectedFiles - file
}
}, },
onFileView = { file -> onFileView = { file ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
try { try {
viewedFileContent = Path(file.absolutePath).readText(Charsets.UTF_8) // ⭐️ [수정] JSON을 읽어 본문 내용만 표시
val data = jsonParser.decodeFromString<ScrapedData>(Path(file.absolutePath).readText(Charsets.UTF_8))
viewedFileContent = """
[원문] ${data.sourceUrl}
[이미지] ${data.imageUrl ?: "없음"}
--------------------
${data.content}
""".trimIndent()
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}")
viewedFileContent = "파일을 읽는 중 오류가 발생했습니다." viewedFileContent = "파일을 읽는 중 오류가 발생했습니다."
@ -330,13 +481,31 @@ fun App() {
} }
}, },
onGeneratePost = { onGeneratePost = {
if (apiKey.isBlank() || workspaceSlug.isBlank() || modelName.isBlank()) {
logMessage(logMessages, "⚠️ API 키, 슬러그, 모델 이름을 모두 입력해주세요.")
return@WorkflowTab
}
if (selectedFiles.isNotEmpty()) { if (selectedFiles.isNotEmpty()) {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isLoading = true isLoading = true
val fileNames = selectedFiles.map { it.name } var uploadedDocIds: List<String> = emptyList()
blogPostResult = generateBlogPostWithLocalLLM(fileNames, userPrompt, logMessages) try {
tabIndex = 2 // 결과 탭으로 이동 // ⭐️ [수정] 선택된 JSON 파일들을 ScrapedData로 변환
isLoading = false val selectedData = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
uploadedDocIds = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug)
if (uploadedDocIds.isNotEmpty()) {
// ⭐️ [수정] ScrapedData 리스트와 모델 이름을 전달
blogPostResult = generateBlogPostWithLocalLLM(selectedData, userPrompt, modelName, logMessages, apiKey, workspaceSlug)
tabIndex = 2
} else {
logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.")
blogPostResult = "파일 업로드 실패"
}
} finally {
cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey)
isLoading = false
}
} }
} else { } else {
logMessage(logMessages, "⚠️ 블로그 글을 생성할 파일을 선택해주세요.") logMessage(logMessages, "⚠️ 블로그 글을 생성할 파일을 선택해주세요.")
@ -352,36 +521,23 @@ fun App() {
@Composable @Composable
fun WorkflowTab( fun WorkflowTab(
isLoading: Boolean, isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>,
keywords: List<String>, scrapedFiles: List<File>, selectedFiles: Set<File>, viewedFileContent: String,
searchResults: List<SearchResult>, userPrompt: String, onUserPromptChange: (String) -> Unit, onFetchTrends: () -> Unit,
scrapedFiles: List<File>, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit,
selectedFiles: Set<File>, onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit,
viewedFileContent: String, onFileView: (File) -> Unit, onGeneratePost: () -> Unit
userPrompt: String,
onUserPromptChange: (String) -> Unit,
onFetchTrends: () -> Unit,
onKeywordSelect: (String) -> Unit,
onSearchResultSelect: (SearchResult) -> Unit,
onRefreshFiles: () -> Unit,
onFileSelectToggle: (File, Boolean) -> Unit,
onFileView: (File) -> Unit,
onGeneratePost: () -> Unit
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// 1열: 트렌드 키워드
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) { Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") }
Text("트렌드 가져오기")
}
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(keywords) { keyword -> items(keywords) { keyword ->
Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp))
} }
} }
} }
// 2열: 검색 결과
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))
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
@ -393,7 +549,6 @@ fun WorkflowTab(
} }
} }
} }
// 3열: 스크랩된 파일 목록
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(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("저장된 파일", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp)) Text("저장된 파일", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
@ -401,36 +556,19 @@ fun WorkflowTab(
} }
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(scrapedFiles) { file -> items(scrapedFiles) { file ->
Row( Row(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp), Checkbox(checked = file in selectedFiles, onCheckedChange = { isChecked -> onFileSelectToggle(file, isChecked) }, enabled = !isLoading)
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = file in selectedFiles,
onCheckedChange = { isChecked -> onFileSelectToggle(file, isChecked) },
enabled = !isLoading
)
Text(file.name, modifier = Modifier.padding(start = 4.dp), maxLines = 1) Text(file.name, modifier = Modifier.padding(start = 4.dp), maxLines = 1)
} }
} }
} }
} }
// 4열: 내용 확인 및 생성
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(text = viewedFileContent, modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).border(1.dp, Color.LightGray).padding(4.dp), style = MaterialTheme.typography.body2)
text = viewedFileContent,
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).border(1.dp, Color.LightGray).padding(4.dp),
style = MaterialTheme.typography.body2
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("LLM 요청사항", style = MaterialTheme.typography.h6) Text("LLM 요청사항", style = MaterialTheme.typography.h6)
TextField( TextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), placeholder = { Text("예: 선택된 파일들을 종합해서...") })
value = userPrompt,
onValueChange = onUserPromptChange,
modifier = Modifier.fillMaxWidth().height(100.dp),
placeholder = { Text("예: 선택된 파일들을 종합해서...") }
)
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("블로그 글 생성하기 (${selectedFiles.size}개 파일)")
@ -445,12 +583,7 @@ fun WorkflowTab(
@Composable @Composable
fun LogTab(logs: List<String>) { fun LogTab(logs: List<String>) {
TextField( TextField(value = logs.joinToString("\n"), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize().padding(8.dp))
value = logs.joinToString("\n"),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxSize().padding(8.dp)
)
} }
@Composable @Composable
@ -461,14 +594,14 @@ fun ResultTab(result: String) {
} }
// --- 애플리케이션 시작점 --- // --- 애플리케이션 시작점 ---
fun main() = application { fun main() = application {
Window( Window(
onCloseRequest = { onCloseRequest = {
quitChromeDriver()
httpClient.close() httpClient.close()
exitApplication() exitApplication()
}, },
title = "자동 블로그 포스팅 도우미 v2.6" title = "자동 블로그 포스팅 도우미 v4.0 (Final)"
) { ) {
App() App()
} }