diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 22cc198..b14e0b4 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.window.application import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* -// ⭐️ [추가] 타임아웃 설정을 위한 import import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* @@ -29,6 +28,7 @@ import io.ktor.serialization.kotlinx.json.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.jsoup.Jsoup import org.openqa.selenium.By @@ -57,22 +57,32 @@ data class DeleteDocumentsRequest(val deletes: List) data class SearchResult(val title: String, val url: String) +// ⭐️ [추가] 스크랩된 모든 정보를 담는 데이터 클래스 (JSON 저장용) +@Serializable +data class ScrapedData( + val sourceUrl: String, + val imageUrl: String?, + val content: String +) + + // --- 전역 변수 및 헬퍼 --- -// ⭐️ [수정] HttpClient에 타임아웃 설정 추가 private val httpClient = HttpClient(CIO) { install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) } - // LLM이 응답할 시간을 5분으로 넉넉하게 설정 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, message: String) { @@ -154,7 +164,8 @@ suspend fun searchOnGoogle(keyword: String, logs: MutableList, isBrowser return results } -suspend fun scrapeArticleByUrl(url: String, logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): String { +// ⭐️ [수정] 반환 타입을 ScrapedData로 변경하고 이미지 URL 추출 로직 추가 +suspend fun scrapeArticleByUrl(url: String, logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? { logMessage(logs, "URL 스크랩 시작: $url") val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new") @@ -166,17 +177,24 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList, isBrowser Thread.sleep(2000) val doc = Jsoup.parse(currentDriver.pageSource) 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()) { logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.") - "기사 본문을 찾을 수 없습니다." + null } else { - logMessage(logs, "✅ URL 스크랩 완료. (총 ${articleContent.length}자)") - articleContent + logMessage(logs, "✅ URL 스크랩 완료. (이미지: ${if (imageUrl.isNotBlank()) "있음" else "없음"})") + ScrapedData( + sourceUrl = url, + imageUrl = imageUrl.ifBlank { null }, + content = articleContent + ) } } catch (e: Exception) { logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}") quitChromeDriver() - "페이지 스크랩 중 오류 발생: ${e.message}" + null } finally { if (!keepSession) quitChromeDriver() } @@ -185,7 +203,7 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList, isBrowser suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKey: String, workspaceSlug: String): List { logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...") - val uploadedDocLocations = mutableListOf() + val uploadedDocIds = mutableListOf() for (file in files) { logMessage(logs, " - '${file.name}' 업로드 중...") @@ -195,7 +213,9 @@ suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKe setBody(MultiPartFormDataContent( formData { append("addToWorkspaces", workspaceSlug) - append("file", file.readBytes(), Headers.build { + // ⭐️ [수정] JSON 파일의 내용을 텍스트로 읽어 전송 + val scrapedData = jsonParser.decodeFromString(file.readText()) + append("file", scrapedData.content.toByteArray(), Headers.build { append(HttpHeaders.ContentType, ContentType.Text.Plain) append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") }) @@ -205,10 +225,10 @@ suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKe if (response.status.isSuccess()) { val uploadResponse = response.body() - val location = uploadResponse.documents.firstOrNull()?.location - if (location != null) { - uploadedDocLocations.add(location) - logMessage(logs, " ✅ '${file.name}' 업로드 성공.") + val docId = uploadResponse.documents.firstOrNull()?.id + if (docId != null) { + uploadedDocIds.add(docId) + logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $docId).") } else { logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.") } @@ -220,18 +240,45 @@ suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKe } } - logMessage(logs, "총 ${files.size}개 중 ${uploadedDocLocations.size}개 파일 업로드 완료.") - return uploadedDocLocations + logMessage(logs, "총 ${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.") + return uploadedDocIds } - -suspend fun generateBlogPostWithLocalLLM(userDirection: String, logs: MutableList, apiKey: String, workspaceSlug: String): String { +// ⭐️ [수정] ScrapedData 리스트와 modelName을 파라미터로 받도록 변경 +suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List, userDirection: String, modelName: String, logs: MutableList, 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 = """ - 내 지식 베이스에 있는 문서들을 종합적으로 참고해서 아래 '요청사항'에 맞춰 SEO에 최적화된 블로그 글을 작성해줘. 제목도 2~3개 추천해줘. + 당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. + 아래 '참고자료'와 '요청사항'을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. + + --- 참고자료 --- + $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() val requestBody = AnythingLLMChatRequest(message = finalPrompt) @@ -245,7 +292,8 @@ suspend fun generateBlogPostWithLocalLLM(userDirection: String, logs: MutableLis val responseBodyText = response.bodyAsText() logMessage(logs, "LLM 응답 수신: $responseBodyText") - val chatResponse = Json.decodeFromString(responseBodyText) + val chatResponse = jsonParser.decodeFromString(responseBodyText) + logMessage(logs, "✅ LLM 블로그 글 생성 완료.") return chatResponse.textResponse @@ -255,16 +303,14 @@ suspend fun generateBlogPostWithLocalLLM(userDirection: String, logs: MutableLis } } -// ⭐️ [수정] cleanup 함수의 URL을 올바른 주소로 변경 -suspend fun cleanupLLMWorkspace(locations: List, logs: MutableList, apiKey: String) { - if (locations.isEmpty()) return - logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${locations.size}개 삭제)...") +suspend fun cleanupLLMWorkspace(docIds: List, logs: MutableList, apiKey: String) { + if (docIds.isEmpty()) return + logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...") try { - // [수정] URL을 /workspace/{slug}/update-documents 에서 /documents/delete 로 변경 - val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/documents/delete") { + val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") { header("Authorization", "Bearer $apiKey") contentType(ContentType.Application.Json) - setBody(DeleteDocumentsRequest(deletes = locations)) + setBody(DeleteDocumentsRequest(deletes = docIds)) } if (response.status.isSuccess()) { logMessage(logs, "✅ LLM 워크스페이스 정리 완료.") @@ -276,22 +322,24 @@ suspend fun cleanupLLMWorkspace(locations: List, logs: MutableList, folderPath: String) { +// ⭐️ [수정] ScrapedData를 JSON 파일로 저장하는 함수 +fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList, folderPath: String) { try { val directory = File(folderPath) if (!directory.exists()) directory.mkdirs() 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) - file.writeText(content) - logMessage(logs, "✅ '${file.path}'에 스크랩 내용 저장 완료.") + file.writeText(jsonParser.encodeToString(data)) + logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.") } catch (e: Exception) { logMessage(logs, "❌ 파일 저장 중 오류: ${e.message}") } } -fun loadScrapedFiles(logs: MutableList, folderPath: String): List { +// ⭐️ [수정] .json 파일을 읽어오는 함수 +fun loadScrapedJsonFiles(logs: MutableList, folderPath: String): List { logMessage(logs, "'$folderPath'에서 파일 목록 로딩...") return try { val directory = File(folderPath) @@ -299,7 +347,8 @@ fun loadScrapedFiles(logs: MutableList, folderPath: String): List logMessage(logs, "⚠️ '$folderPath' 폴더를 찾을 수 없습니다.") return emptyList() } - val files = directory.listFiles { _, name -> name.endsWith(".txt") } + // ⭐️ [수정] .json 파일만 대상으로 함 + val files = directory.listFiles { _, name -> name.endsWith(".json") } ?.sortedByDescending { it.lastModified() } ?: emptyList() logMessage(logs, "✅ 파일 ${files.size}개 로딩 완료.") @@ -310,7 +359,6 @@ fun loadScrapedFiles(logs: MutableList, folderPath: String): List } } - // --- UI 컴포넌트 --- @Composable fun App() { @@ -318,7 +366,6 @@ fun App() { val tabs = listOf("워크플로우", "통신 로그", "블로그 결과") val coroutineScope = rememberCoroutineScope() - // --- 상태 관리 --- var keywords by remember { mutableStateOf>(emptyList()) } var searchResults by remember { mutableStateOf>(emptyList()) } var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") } @@ -327,33 +374,26 @@ fun App() { var selectedKeyword by remember { mutableStateOf("") } var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") } - // 설정 상태 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>(emptyList()) } var selectedFiles by remember { mutableStateOf>(emptySet()) } var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") } LaunchedEffect(scrapedFolderPath) { - scrapedFiles = loadScrapedFiles(logMessages, scrapedFolderPath) - } - - LaunchedEffect(scrapedFolderPath) { - prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) - } - - LaunchedEffect(apiKey) { - prefs.put(PREF_API_KEY, apiKey) - } - - LaunchedEffect(workspaceSlug) { - prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) + scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) } + 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 { Column(modifier = Modifier.fillMaxSize()) { @@ -365,30 +405,16 @@ fun App() { Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f)) } Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = scrapedFolderPath, - onValueChange = { scrapedFolderPath = it }, - label = { Text("스크랩 저장 폴더 경로") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) + 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() - ) + 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 = workspaceSlug, - onValueChange = { workspaceSlug = it }, - label = { Text("AnythingLLM Workspace Slug") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) + // ⭐️ [추가] 모델 이름 입력 필드 + OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true) } Divider() @@ -397,7 +423,6 @@ fun App() { Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } } - when (tabIndex) { 0 -> WorkflowTab( isLoading = isLoading, keywords = keywords, searchResults = searchResults, @@ -421,17 +446,18 @@ fun App() { onSearchResultSelect = { result -> coroutineScope.launch(Dispatchers.IO) { isLoading = true - val content = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession) - if (!content.contains("오류 발생")) { - saveContentToFile(selectedKeyword, content, logMessages, scrapedFolderPath) - scrapedFiles = loadScrapedFiles(logMessages, scrapedFolderPath) + // ⭐️ [수정] ScrapedData를 받아 JSON으로 저장 + val data = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession) + if (data != null) { + saveDataToJsonFile(selectedKeyword, data, logMessages, scrapedFolderPath) + scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) } isLoading = false } }, onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { - scrapedFiles = loadScrapedFiles(logMessages, scrapedFolderPath) + scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) } }, onFileSelectToggle = { file, isSelected -> @@ -440,7 +466,14 @@ fun App() { onFileView = { file -> coroutineScope.launch(Dispatchers.IO) { try { - viewedFileContent = Path(file.absolutePath).readText(Charsets.UTF_8) + // ⭐️ [수정] JSON을 읽어 본문 내용만 표시 + val data = jsonParser.decodeFromString(Path(file.absolutePath).readText(Charsets.UTF_8)) + viewedFileContent = """ + [원문] ${data.sourceUrl} + [이미지] ${data.imageUrl ?: "없음"} + -------------------- + ${data.content} + """.trimIndent() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") viewedFileContent = "파일을 읽는 중 오류가 발생했습니다." @@ -448,30 +481,29 @@ fun App() { } }, onGeneratePost = { - if (apiKey.isBlank()) { - logMessage(logMessages, "⚠️ API 키를 먼저 입력해주세요.") - return@WorkflowTab - } - if (workspaceSlug.isBlank()) { - logMessage(logMessages, "⚠️ 워크스페이스 슬러그를 먼저 입력해주세요.") + if (apiKey.isBlank() || workspaceSlug.isBlank() || modelName.isBlank()) { + logMessage(logMessages, "⚠️ API 키, 슬러그, 모델 이름을 모두 입력해주세요.") return@WorkflowTab } if (selectedFiles.isNotEmpty()) { coroutineScope.launch(Dispatchers.IO) { isLoading = true - var uploadedLocations: List = emptyList() + var uploadedDocIds: List = emptyList() try { - uploadedLocations = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug) - if (uploadedLocations.isNotEmpty()) { - blogPostResult = generateBlogPostWithLocalLLM(userPrompt, logMessages, apiKey, workspaceSlug) + // ⭐️ [수정] 선택된 JSON 파일들을 ScrapedData로 변환 + val selectedData = selectedFiles.map { jsonParser.decodeFromString(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 { - // [수정] cleanup 함수에서 더 이상 slug가 필요 없음 - cleanupLLMWorkspace(uploadedLocations, logMessages, apiKey) + cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey) isLoading = false } } @@ -569,7 +601,7 @@ fun main() = application { httpClient.close() exitApplication() }, - title = "자동 블로그 포스팅 도우미 v3.1" + title = "자동 블로그 포스팅 도우미 v4.0 (Final)" ) { App() }