This commit is contained in:
lunaticbum 2025-10-01 17:48:44 +09:00
parent 30f4635072
commit b95aa050ad

View File

@ -10,14 +10,19 @@ 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
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.*
@ -27,118 +32,139 @@ import kotlinx.serialization.Serializable
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)
// --- 전역 변수 및 헬퍼 --- // --- 전역 변수 및 헬퍼 ---
// ⭐️ [수정] HttpClient에 타임아웃 설정 추가
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 })
}
// LLM이 응답할 시간을 5분으로 넉넉하게 설정
install(HttpTimeout) {
requestTimeoutMillis = 300000
} }
} }
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"
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 { suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): String {
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()
if (articleContent.isBlank()) { if (articleContent.isBlank()) {
logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.") logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.")
@ -149,47 +175,111 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowser
} }
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}") logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}")
e.printStackTrace() quitChromeDriver()
"페이지 스크랩 중 오류 발생: ${e.message}" "페이지 스크랩 중 오류 발생: ${e.message}"
} finally { } finally {
driver.quit() if (!keepSession) quitChromeDriver()
} }
} }
suspend fun generateBlogPostWithLocalLLM(fileNames: List<String>, userDirection: String, logs: MutableList<String>): String {
logMessage(logs, "LLM 블로그 글 생성 요청 (${fileNames.size}개 파일 기반)...") suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> {
val apiKey = System.getenv("ANYTHINGLLM_API_KEY") ?: "YOUR_API_KEY_HERE" logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
val fileNamesString = fileNames.joinToString(", ") val uploadedDocLocations = 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)
append("file", file.readBytes(), Headers.build {
append(HttpHeaders.ContentType, ContentType.Text.Plain)
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
})
}
))
}
if (response.status.isSuccess()) {
val uploadResponse = response.body<UploadResponse>()
val location = uploadResponse.documents.firstOrNull()?.location
if (location != null) {
uploadedDocLocations.add(location)
logMessage(logs, " ✅ '${file.name}' 업로드 성공.")
} 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}개 중 ${uploadedDocLocations.size}개 파일 업로드 완료.")
return uploadedDocLocations
}
suspend fun generateBlogPostWithLocalLLM(userDirection: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
logMessage(logs, "LLM 블로그 글 생성 요청...")
val finalPrompt = """ val finalPrompt = """
지식 베이스에 있는 다음 문서들을 종합적으로 참고해서 아래 '요청사항' 맞춰 SEO에 최적화된 블로그 글을 작성해줘. 제목도 2~3 추천해줘. 지식 베이스에 있는 문서들을 종합적으로 참고해서 아래 '요청사항' 맞춰 SEO에 최적화된 블로그 글을 작성해줘. 제목도 2~3 추천해줘.
--- 참고 문서 ---
$fileNamesString
--- 요청사항 --- --- 요청사항 ---
$userDirection $userDirection
""".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 = Json.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>) { // ⭐️ [수정] cleanup 함수의 URL을 올바른 주소로 변경
suspend fun cleanupLLMWorkspace(locations: List<String>, logs: MutableList<String>, apiKey: String) {
if (locations.isEmpty()) return
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${locations.size}개 삭제)...")
try { try {
val directory = File("/Users/jibumhan/autoblog_content") // [수정] URL을 /workspace/{slug}/update-documents 에서 /documents/delete 로 변경
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/documents/delete") {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(DeleteDocumentsRequest(deletes = locations))
}
if (response.status.isSuccess()) {
logMessage(logs, "✅ LLM 워크스페이스 정리 완료.")
} else {
logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}")
}
} catch (e: Exception) {
logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}")
}
}
fun saveContentToFile(keyword: String, content: String, 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" val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.txt"
@ -198,16 +288,15 @@ fun saveContentToFile(keyword: String, content: String, logs: MutableList<String
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> { fun loadScrapedFiles(logs: MutableList<String>, folderPath: String): List<File> {
logMessage(logs, "스크랩된 파일 목록 로딩...") 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") } val files = directory.listFiles { _, name -> name.endsWith(".txt") }
@ -223,7 +312,6 @@ 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) }
@ -238,30 +326,69 @@ 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 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 = loadScrapedFiles(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)
} }
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
)
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))
OutlinedTextField(
value = workspaceSlug,
onValueChange = { workspaceSlug = it },
label = { Text("AnythingLLM Workspace Slug") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
) )
Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible })
} }
Divider() Divider()
@ -273,18 +400,13 @@ fun App() {
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,32 +414,28 @@ 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) val content = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)
if (!content.contains("오류 발생")) { if (!content.contains("오류 발생")) {
saveContentToFile(selectedKeyword, content, logMessages) saveContentToFile(selectedKeyword, content, logMessages, scrapedFolderPath)
scrapedFiles = loadScrapedFiles(logMessages) // 저장 후 목록 새로고침 scrapedFiles = loadScrapedFiles(logMessages, scrapedFolderPath)
} }
isLoading = false isLoading = false
} }
}, },
onRefreshFiles = { onRefreshFiles = {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
scrapedFiles = loadScrapedFiles(logMessages) scrapedFiles = loadScrapedFiles(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) {
@ -330,14 +448,33 @@ fun App() {
} }
}, },
onGeneratePost = { onGeneratePost = {
if (apiKey.isBlank()) {
logMessage(logMessages, "⚠️ API 키를 먼저 입력해주세요.")
return@WorkflowTab
}
if (workspaceSlug.isBlank()) {
logMessage(logMessages, "⚠️ 워크스페이스 슬러그를 먼저 입력해주세요.")
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 uploadedLocations: List<String> = emptyList()
blogPostResult = generateBlogPostWithLocalLLM(fileNames, userPrompt, logMessages) try {
tabIndex = 2 // 결과 탭으로 이동 uploadedLocations = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug)
if (uploadedLocations.isNotEmpty()) {
blogPostResult = generateBlogPostWithLocalLLM(userPrompt, logMessages, apiKey, workspaceSlug)
tabIndex = 2
} else {
logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.")
blogPostResult = "파일 업로드 실패"
}
} finally {
// [수정] cleanup 함수에서 더 이상 slug가 필요 없음
cleanupLLMWorkspace(uploadedLocations, logMessages, apiKey)
isLoading = false isLoading = false
} }
}
} else { } else {
logMessage(logMessages, "⚠️ 블로그 글을 생성할 파일을 선택해주세요.") logMessage(logMessages, "⚠️ 블로그 글을 생성할 파일을 선택해주세요.")
} }
@ -352,36 +489,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 +517,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 +524,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 +551,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 +562,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 = "자동 블로그 포스팅 도우미 v3.1"
) { ) {
App() App()
} }