From 6d66a0147ce7d315e6a8cb1b92207f00c2a1aa1a Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Mon, 13 Oct 2025 13:26:39 +0900 Subject: [PATCH] ... --- src/main/kotlin/Main.kt | 404 +---------------------- src/main/kotlin/core/BrowserManager.kt | 27 ++ src/main/kotlin/core/FileManager.kt | 29 ++ src/main/kotlin/core/LlmApiService.kt | 195 +++++++++++ src/main/kotlin/core/ScrapingService.kt | 65 ++++ src/main/kotlin/models/ApiData.kt | 36 ++ src/main/kotlin/models/AppData.kt | 14 + src/main/kotlin/ui/App.kt | 266 +++++++++++++++ src/main/kotlin/ui/tabs.kt | 319 ++++++++++++++++++ src/main/kotlin/ui/widgets/FileDialog.kt | 22 ++ src/main/kotlin/utils/Global.kt | 33 ++ 11 files changed, 1012 insertions(+), 398 deletions(-) create mode 100644 src/main/kotlin/core/BrowserManager.kt create mode 100644 src/main/kotlin/core/FileManager.kt create mode 100644 src/main/kotlin/core/LlmApiService.kt create mode 100644 src/main/kotlin/core/ScrapingService.kt create mode 100644 src/main/kotlin/models/ApiData.kt create mode 100644 src/main/kotlin/models/AppData.kt create mode 100644 src/main/kotlin/ui/App.kt create mode 100644 src/main/kotlin/ui/tabs.kt create mode 100644 src/main/kotlin/ui/widgets/FileDialog.kt create mode 100644 src/main/kotlin/utils/Global.kt diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index ef62109..e26af59 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.window.application import coil3.ImageLoader import coil3.compose.rememberAsyncImagePainter import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import core.BrowserManager.quitChromeDriver import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* @@ -36,420 +37,27 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonClassDiscriminator import org.jsoup.Jsoup import org.openqa.selenium.By import org.openqa.selenium.WebDriverException import org.openqa.selenium.chrome.ChromeDriver import org.openqa.selenium.chrome.ChromeOptions +import ui.App +import utils.Global.httpClient import java.awt.FileDialog import java.awt.Frame +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection import java.io.File import java.text.SimpleDateFormat import java.util.* import java.util.prefs.Preferences import kotlin.coroutines.cancellation.CancellationException -// --- 데이터 클래스 정의 (Sealed Interface 사용) --- -@Serializable -@JsonClassDiscriminator("type") -sealed interface SealedLLMStreamResponse { - @Serializable - @SerialName("textResponseChunk") - data class TextResponseChunk(val uuid: String, val textResponse: String?, val sources: List = emptyList(), val close: Boolean, val error: Boolean) : SealedLLMStreamResponse - @Serializable - @SerialName("finalizeResponseStream") - data class FinalizeResponseStream(val uuid: String, val chatId: Int, val metrics: Metrics?, val close: Boolean, val error: Boolean) : SealedLLMStreamResponse - @Serializable - @SerialName("abort") - data class AbortResponse(val uuid: String? = null, val textResponse: String? = null, val close: Boolean = true, val error: Boolean? = null) : SealedLLMStreamResponse -} -@Serializable data class Source(val id: String? = null, val text: String? = null, val location: String? = null, val distance: Float? = null, val score: Float? = null) -@Serializable data class Metrics(@SerialName("completion_tokens") val completionTokens: Int, @SerialName("prompt_tokens") val promptTokens: Int, @SerialName("total_tokens") val totalTokens: Int, val outputTps: Double? = null, val duration: Double? = null) -// 기존 API에서 사용하는 데이터 클래스들 -@Serializable data class AnythingLLMChatRequest(val message: String) -@Serializable data class AnythingLLMChatResponse(val textResponse: String) -@Serializable data class UploadResponse(val documents: List) { @Serializable data class Document(val id: String, val location: String) } -@Serializable data class DeleteDocumentsRequest(val deletes: List) -data class SearchResult(val title: String, val url: String) - -@Serializable -data class ScrapedData(val sourceUrl: String, val selectedImageUrls: List, val allImageUrls: List, val content: String) - -// --- 전역 변수 및 헬퍼 --- -private val httpClient = HttpClient(CIO) { - install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) } - install(HttpTimeout) { requestTimeoutMillis = 900000 } -} -private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = 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" -private const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" -private const val PREF_MODEL_NAME = "model_name" - -fun logMessage(logs: MutableList, message: String) { - val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) - 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!! -} -private fun quitChromeDriver() { driver?.quit(); driver = null } - -// --- 핵심 기능 함수들 (스크랩, 업로드 등) --- -suspend fun fetchGoogleTrends(logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): List { - logMessage(logs, "Google Trends 페이지 스크랩 시작...") - val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR" - val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") } - val currentDriver = getChromeDriver(options) - return try { - currentDriver.get(trendsUrl) - Thread.sleep(5000) - currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")).mapNotNull { it.text.takeIf(String::isNotBlank) }.also { logMessage(logs, "✅ Google Trends 키워드 ${it.size}개 스크랩 완료.") } - } catch (e: Exception) { - logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}"); quitChromeDriver(); emptyList() - } finally { if (!keepSession) quitChromeDriver() } -} - -suspend fun searchOnGoogle(keyword: String, logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): List { - logMessage(logs, "'$keyword' 키워드로 Google 검색 시작...") - val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") } - val currentDriver = getChromeDriver(options) - val results = mutableListOf() - try { - currentDriver.get("https://www.google.com/search?q=$keyword") - Thread.sleep(10000) - currentDriver.findElements(By.cssSelector("div[data-rpos]")).take(10).forEach { element -> - try { - val title = element.findElement(By.cssSelector("h3")).text - val url = element.findElement(By.cssSelector("a")).getAttribute("href") - if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url)) - } catch (e: Exception) { /* 개별 오류 무시 */ } - } - logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.") - } catch (e: Exception) { - logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); quitChromeDriver() - } finally { if (!keepSession) quitChromeDriver() } - return results -} - -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"); addArguments("--disable-gpu") } - val currentDriver = getChromeDriver(options) - return try { - currentDriver.get(url) - Thread.sleep(2000) - val doc = Jsoup.parse(currentDriver.pageSource) - val articleContent = doc.select("#app, .app, article, .article-body, #article_body, .news-article-body-view").text() - val allImages = doc.select("article img, .article-body img, #article_body img, .news-article-body-view img").map { it.absUrl("src") }.filter { it.isNotBlank() && it.startsWith("http") }.distinct() - if (articleContent.isBlank()) { logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다."); null } - else { logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)"); ScrapedData(sourceUrl = url, selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent) } - } catch (e: Exception) { - logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}"); quitChromeDriver(); null - } finally { if (!keepSession) quitChromeDriver() } -} - -suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKey: String, workspaceSlug: String): List { - logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...") - val uploadedDocIds = mutableListOf() - files.forEach { file -> - 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) - 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}\"") }) - })) - } - if (response.status.isSuccess()) { - val uploadResponse = response.body() - uploadResponse.documents.firstOrNull()?.id?.let { uploadedDocIds.add(it); logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).") } ?: 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 -} - -suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List, userOwnContent: String, allSelectedImages: List, userDirection: String, logs: MutableList, apiKey: String, workspaceSlug: String): String { - logMessage(logs, "LLM 블로그 글 생성 요청...") - val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) { scrapedDataList.mapIndexed { index, data -> "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" }.joinToString("\n").let { "--- 참고자료 ---\n$it" } } else { "--- 주요 내용 ---\n아래 내용을 바탕으로 글을 작성해주세요.\n$userOwnContent" } - val imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n")}" else "" - val finalPrompt = """당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. $contentSourcePromptPart --- 요청사항 ---\n1. 사용자 요청: "$userDirection"\n2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.\n3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요. $imagePromptPart\n5. 글 마지막에 출처에 대한 언급은 절대 하지 마세요.""" - try { - val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") { - header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt)) - } - val chatResponse = jsonParser.decodeFromString(response.bodyAsText()) - logMessage(logs, "✅ LLM 블로그 글 생성 완료.") - return chatResponse.textResponse - } catch (e: Exception) { - logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}"); return "블로그 글 생성 실패: ${e.message}" - } -} - -suspend fun cleanupLLMWorkspace(docIds: List, logs: MutableList, apiKey: String) { - if (docIds.isEmpty()) return - logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...") - try { - 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}") } -} - -@Serializable data class Attachment(val name: String, val mime: String, val contentString: String) -@Serializable data class StreamChatRequest(val message: String, val attachments: List, val mode: String, val sessionId: String) - -suspend fun analyzeReceiptsWithStreamChat(files: List, apiKey: String, receiptWorkspaceSlug: String, logs: MutableList, resultState: MutableStateFlow, receiptContext: String) { - if (apiKey.isBlank() || receiptWorkspaceSlug.isBlank() || files.isEmpty()) { - val missing = listOfNotNull(if (apiKey.isBlank()) "API Key" else null, if (receiptWorkspaceSlug.isBlank()) "영수증 Workspace" else null, if (files.isEmpty()) "영수증 파일" else null).joinToString(); logs.add("⚠️ 영수증 분석을 시작할 수 없습니다. ($missing 누락)"); return - } - logMessage(logs, "Starting receipt analysis with stream-chat mode...") - resultState.value = "영수증 분석 중..." - try { - val attachments = files.map { file -> val base64Image = Base64.getEncoder().encodeToString(file.readBytes()); val mimeType = when (file.extension.lowercase()) { "png" -> "image/png"; "jpeg", "jpg" -> "image/jpeg"; else -> "application/octet-stream" }; Attachment(file.name, mimeType, "data:$mimeType;base64,$base64Image") } - val basePrompt = "첨부된 영수증 이미지들을 분석해줘." - val finalPrompt = if (receiptContext.isNotBlank()) """$basePrompt --- 추가 정보 --- $receiptContext --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘.""" else basePrompt - logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}") - val requestBody = StreamChatRequest(message = finalPrompt, attachments = attachments, mode = "chat", sessionId = "receipt-analysis-${System.currentTimeMillis()}") - httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") { - accept(ContentType.Application.Json); header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(requestBody) - }.execute { response -> - if (response.status.isSuccess()) { - val channel: ByteReadChannel = response.bodyAsChannel() - var fullResponseText = "" - while (!channel.isClosedForRead) { - val line = channel.readUTF8Line() ?: continue - if (line.startsWith("data:")) { - val jsonChunk = line.removePrefix("data:").trim() - if (jsonChunk.isEmpty() || jsonChunk == "[DONE]") continue - try { - when (val streamObject = jsonParser.decodeFromString(jsonChunk)) { - is SealedLLMStreamResponse.TextResponseChunk -> { val textChunk = streamObject.textResponse ?: ""; if (textChunk.isNotBlank() && textChunk != "-") { fullResponseText += textChunk; resultState.value = fullResponseText } } - is SealedLLMStreamResponse.FinalizeResponseStream -> { logMessage(logs, "✅ Stream finalized."); streamObject.metrics?.let { logs.add(" - Metrics: total_tokens=${it.totalTokens}, duration=${it.duration}s") }; break } - is SealedLLMStreamResponse.AbortResponse -> { logMessage(logs, "⚠️ Stream aborted by server. Reason: ${streamObject.textResponse}"); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break } - } - } catch (e: Exception) { logMessage(logs, "❌ Chunk parsing error: ${e.message} | Chunk: $jsonChunk") } - } - } - logMessage(logs, "Receipt analysis stream processing finished.") - } else { val errorBody = response.bodyAsText(); logMessage(logs, "❌ Error during receipt analysis: ${response.status} - $errorBody"); resultState.value = "API 오류: ${response.status}" } - } - } catch (e: CancellationException) { throw e } - catch (e: Exception) { logMessage(logs, "❌ Exception in receipt analysis: ${e.message}"); resultState.value = e.message ?: "알 수 없는 오류" } -} - -fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList, folderPath: String): File { - val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() } - val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "") - val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json" - val file = File(directory, fileName) - file.writeText(jsonParser.encodeToString(data)) - logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.") - return file -} - -fun loadScrapedJsonFiles(logs: MutableList, folderPath: String): List { - logMessage(logs, "'$folderPath'에서 파일 목록 로딩...") - return File(folderPath).listFiles { _, name -> name.endsWith(".json") }?.sortedByDescending { it.lastModified() }.orEmpty().also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") } -} - -@Composable -fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List) -> Unit) { - val fileDialog = remember { FileDialog(parent, "파일 선택", FileDialog.LOAD).apply { isMultipleMode = true } } - LaunchedEffect(Unit) { fileDialog.isVisible = true; onCloseRequest(fileDialog.files.toList()) } -} - -// --- UI 컴포넌트 --- -@Composable -fun App(imageLoader: ImageLoader) { - val coroutineScope = rememberCoroutineScope() - var tabIndex by remember { mutableStateOf(0) } - val tabs = listOf("워크플로우", "통신 로그", "블로그 결과") - var keywords by remember { mutableStateOf>(emptyList()) } - var searchResults by remember { mutableStateOf>(emptyList()) } - var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") } - val logMessages = remember { mutableStateListOf() } - var isLoading by remember { mutableStateOf(false) } - var selectedKeyword by remember { mutableStateOf("") } - var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") } - var isBrowserVisible by remember { mutableStateOf(true) } - var keepBrowserSession by remember { mutableStateOf(true) } - 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-workspace")) } - var receiptWorkspaceSlug by remember { mutableStateOf(prefs.get(PREF_RECEIPT_WORKSPACE_SLUG, "receipts")) } - var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "Llama-3.1-8B-Vision")) } - var scrapedFiles by remember { mutableStateOf>(emptyList()) } - var selectedFiles by remember { mutableStateOf>(emptySet()) } - var currentlyOpenFile by remember { mutableStateOf(null) } - var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") } - var imagesForSelection by remember { mutableStateOf>(emptyList()) } - var currentSelectedImages by remember { mutableStateOf>(emptySet()) } - var manualKeyword by remember { mutableStateOf("") } - var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다.") } - var isImageUploadDialogVisible by remember { mutableStateOf(false) } - var uploadedImageFiles by remember { mutableStateOf>(emptyList()) } - var receiptFiles by remember { mutableStateOf>(emptyList()) } - var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") } - var isReceiptDialogVisible by remember { mutableStateOf(false) } - var analysisJob by remember { mutableStateOf(null) } - var receiptContextPrompt by remember { mutableStateOf("") } - - LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath); prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) } - LaunchedEffect(apiKey) { prefs.put(PREF_API_KEY, apiKey) } - LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) } - LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) } - LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) } - - if (isImageUploadDialogVisible) { FileDialog { selected -> isImageUploadDialogVisible = false; if (selected.isNotEmpty()) { uploadedImageFiles = uploadedImageFiles + selected; logMessage(logMessages, "✅ 개인 이미지 파일 ${selected.size}개 추가됨.") } } } - if (isReceiptDialogVisible) { FileDialog { selected -> isReceiptDialogVisible = false; if (selected.isNotEmpty()) { receiptFiles = receiptFiles + selected; logMessage(logMessages, "🧾 영수증 이미지 ${selected.size}개 추가됨.") } } } - - MaterialTheme { - Column(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it }); Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f)); Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it }); Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f)) } - 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)) - Row(Modifier.fillMaxWidth()) { OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("블로그용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true); Spacer(Modifier.width(4.dp)); OutlinedTextField(value = receiptWorkspaceSlug, onValueChange = { receiptWorkspaceSlug = it }, label = { Text("영수증 처리용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true) } - Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("사용 중인 LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - } - Divider() - TabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } } - when (tabIndex) { - 0 -> WorkflowTab( - isLoading = isLoading, keywords = keywords, searchResults = searchResults, scrapedFiles = scrapedFiles, selectedFiles = selectedFiles, - viewedFileContent = viewedFileContent, imagesForSelection = imagesForSelection, currentSelectedImages = currentSelectedImages, - userPrompt = userPrompt, onUserPromptChange = { userPrompt = it }, imageLoader = imageLoader, - manualKeyword = manualKeyword, onManualKeywordChange = { manualKeyword = it }, - userOwnContent = userOwnContent, onUserOwnContentChange = { userOwnContent = it }, - uploadedImageFiles = uploadedImageFiles, - onFetchTrends = { coroutineScope.launch(Dispatchers.IO) { isLoading = true; keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } }, - onKeywordSelect = { keyword -> selectedKeyword = keyword; manualKeyword = keyword; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } }, - onSearchResultSelect = { result -> coroutineScope.launch(Dispatchers.IO) { isLoading = true; scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)?.let { data -> val finalKeyword = if (selectedKeyword.isNotBlank()) selectedKeyword else manualKeyword; val savedFile = saveDataToJsonFile(finalKeyword, data, logMessages, scrapedFolderPath); scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath); currentlyOpenFile = savedFile; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() }; isLoading = false } }, - onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) } }, - onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file }, - onFileView = { file -> coroutineScope.launch(Dispatchers.IO) { try { val data = jsonParser.decodeFromString(file.readText()); currentlyOpenFile = file; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") } } }, - onImageSelect = { imageUrl -> currentSelectedImages = if (imageUrl in currentSelectedImages) currentSelectedImages - imageUrl else currentSelectedImages + imageUrl }, - onSaveChanges = { coroutineScope.launch(Dispatchers.IO) { currentlyOpenFile?.let { file -> try { val originalData = jsonParser.decodeFromString(file.readText()); val updatedData = originalData.copy(selectedImageUrls = currentSelectedImages.toList()); file.writeText(jsonParser.encodeToString(updatedData)); logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.") } catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") } } } }, - onUploadImage = { isImageUploadDialogVisible = true }, - onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove }, - onGeneratePost = { if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@WorkflowTab }; coroutineScope.launch(Dispatchers.IO) { isLoading = true; try { val selectedDataList = selectedFiles.map { jsonParser.decodeFromString(it.readText()) }; val isScrapBased = selectedFiles.isNotEmpty(); val finalContent = if(isScrapBased) selectedDataList else emptyList(); val finalUserContent = if(!isScrapBased) userOwnContent else ""; val allImages = if(isScrapBased) selectedDataList.flatMap { it.selectedImageUrls }.distinct() else uploadedImageFiles.map { it.toURI().toString() }; if (!isScrapBased && (userOwnContent.isBlank() || allImages.isEmpty())) { logMessage(logMessages, "⚠️ 직접 포스팅을 하려면 '직접 작성' 내용과 '업로드한 이미지'가 모두 필요합니다."); return@launch }; val uploadedDocIds = if (isScrapBased) uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug).also { if (it.isEmpty()) { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다."); return@launch } } else emptyList(); val resultText = generateBlogPostWithLocalLLM(finalContent, finalUserContent, allImages, userPrompt, logMessages, apiKey, workspaceSlug); val footer = buildString { appendLine("\n\n---"); if (isScrapBased) { appendLine("- 원문 출처:"); finalContent.forEach { appendLine(" - ${it.sourceUrl}") } }; if(allImages.isNotEmpty()){ appendLine("- 사용된 이미지:"); allImages.forEach { appendLine(" - $it")} }; appendLine("- 이 글은 ${modelName} 모델을 활용하여 작성되었습니다.") }; blogPostResult = resultText + footer; tabIndex = 2; if (isScrapBased) cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey) } finally { isLoading = false } } }, - receiptFiles = receiptFiles, - receiptAnalysisResult = receiptAnalysisResult, - onUploadReceipt = { isReceiptDialogVisible = true }, - onRemoveReceipt = { file -> receiptFiles = receiptFiles - file }, - isAnalyzing = analysisJob?.isActive == true, - onAnalyzeReceipts = { - analysisJob = coroutineScope.launch(Dispatchers.IO) { - try { - isLoading = true - val resultFlow = MutableStateFlow("") - val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } } - analyzeReceiptsWithStreamChat(receiptFiles, apiKey, receiptWorkspaceSlug, logMessages, resultFlow, receiptContextPrompt) - uiUpdateJob.cancel() - } catch (e: CancellationException) { logMessage(logMessages, "ℹ️ 영수증 분석이 사용자에 의해 중단되었습니다."); receiptAnalysisResult = "분석이 중단되었습니다." - } catch (e: Exception) { logMessage(logMessages, "❌ 영수증 분석 중 심각한 오류 발생: ${e.message}"); receiptAnalysisResult = "오류 발생: ${e.message}" - } finally { isLoading = false; analysisJob = null } - } - }, - onCancelAnalysis = { analysisJob?.cancel() }, - receiptContextPrompt = receiptContextPrompt, - onReceiptContextPromptChange = { receiptContextPrompt = it } - ) - 1 -> LogTab(logMessages) - 2 -> ResultTab(blogPostResult) - } - } - } -} - -@Composable -fun WorkflowTab( - isLoading: Boolean, keywords: List, searchResults: List, scrapedFiles: List, selectedFiles: Set, - viewedFileContent: String, imagesForSelection: List, currentSelectedImages: Set, - userPrompt: String, imageLoader: ImageLoader, - manualKeyword: String, onManualKeywordChange: (String) -> Unit, - userOwnContent: String, onUserOwnContentChange: (String) -> Unit, - uploadedImageFiles: List, - onUserPromptChange: (String) -> Unit, onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, - onSearchResultSelect: (SearchResult) -> Unit, onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit, - onFileView: (File) -> Unit, onImageSelect: (String) -> Unit, onSaveChanges: () -> Unit, onGeneratePost: () -> Unit, - onUploadImage: () -> Unit, onRemoveUploadedImage: (File) -> Unit, - receiptFiles: List, - receiptAnalysisResult: String, - onUploadReceipt: () -> Unit, - onRemoveReceipt: (File) -> Unit, - onAnalyzeReceipts: () -> Unit, - isAnalyzing: Boolean, - onCancelAnalysis: () -> Unit, - receiptContextPrompt: String, - onReceiptContextPromptChange: (String) -> Unit -) { - Box(modifier = Modifier.fillMaxSize()) { - Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(4.dp)) { - Row(modifier = Modifier.fillMaxWidth()) { OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text("키워드 직접 입력") }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading); Spacer(Modifier.width(4.dp)); Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text("검색") } }; Spacer(Modifier.height(8.dp)); Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") }; Divider(modifier = Modifier.padding(vertical = 8.dp)); LazyColumn(modifier = Modifier.fillMaxSize()) { 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)) { - Text("검색 결과", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); LazyColumn(modifier = Modifier.fillMaxSize()) { items(searchResults) { result -> Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) { Text(result.title, style = MaterialTheme.typography.subtitle1, color = MaterialTheme.colors.primary); Text(result.url, style = MaterialTheme.typography.caption, maxLines = 1) } } } - } - Column(modifier = Modifier.weight(3f).padding(horizontal = 4.dp).verticalScroll(rememberScrollState())) { - Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { Text("저장된 파일 (스크랩 기반)", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp)); Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") } }; Box(modifier = Modifier.heightIn(max = 200.dp)) { LazyColumn { items(scrapedFiles) { file -> Row(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp), 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) } } } }; Spacer(Modifier.height(8.dp)); Text("파일 내용", style = MaterialTheme.typography.subtitle1); Text(text = viewedFileContent, modifier = Modifier.height(100.dp).fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp).verticalScroll(rememberScrollState()), style = MaterialTheme.typography.body2); Spacer(Modifier.height(8.dp)); Row(verticalAlignment = Alignment.CenterVertically) { Text("대표 이미지 선택 (다중 가능)", style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f)); Button(onClick = onSaveChanges, enabled = !isLoading && imagesForSelection.isNotEmpty()) { Text("선택 이미지 저장") } }; LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) { items(imagesForSelection) { imageUrl -> val isSelected = imageUrl in currentSelectedImages; 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(16.dp)) - Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) { - Text("직접 작성 (여행 기록 등)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); OutlinedTextField(value = userOwnContent, onValueChange = onUserOwnContentChange, modifier = Modifier.fillMaxWidth().height(150.dp), label = { Text("블로그에 올릴 내용을 직접 작성하세요.") }); Spacer(Modifier.height(8.dp)); Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("내 PC에서 이미지 업로드") }; LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { items(uploadedImageFiles) { file -> Box(modifier = Modifier.padding(4.dp)) { Image(painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader), contentDescription = "Uploaded Image", modifier = Modifier.size(100.dp).clickable { onRemoveUploadedImage(file) }, contentScale = ContentScale.Crop) } } } - } - Spacer(Modifier.height(16.dp)) - Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) { - Text("영수증 분석기", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("영수증 이미지 업로드") }; LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { items(receiptFiles) { file -> Box(modifier = Modifier.padding(4.dp)) { Image(painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader), contentDescription = "Receipt Image", modifier = Modifier.size(100.dp).clickable { onRemoveReceipt(file) }, contentScale = ContentScale.Crop) } } }; Spacer(Modifier.height(8.dp)); - OutlinedTextField(value = receiptContextPrompt, onValueChange = onReceiptContextPromptChange, modifier = Modifier.fillMaxWidth(), placeholder = { Text("추가 정보를 입력하면 더 정확하게 분석할 수 있습니다.") }, label = { Text("추가 정보 입력 (예: 부산 출장 경비)") }) - Spacer(Modifier.height(8.dp)); - if (isAnalyzing) { - Button(onClick = onCancelAnalysis, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error)) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp); Spacer(Modifier.width(8.dp)); Text("분석 중단하기") } } - } else { - Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { Text("선택한 영수증 분석 시작 (${receiptFiles.size}개)") } - } - Spacer(Modifier.height(8.dp)); OutlinedTextField(value = receiptAnalysisResult, onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth().height(180.dp), label = { Text("분석 결과 (내용 복사하여 사용)") }) - } - } - Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(8.dp)) { - Text("LLM 요청사항", style = MaterialTheme.typography.h6); TextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), placeholder = { Text("예: 선택된 파일들을 종합해서...") }); Spacer(Modifier.height(8.dp)); Button(onClick = onGeneratePost, enabled = !isLoading && (selectedFiles.isNotEmpty() || (userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty())), modifier = Modifier.fillMaxWidth()) { val buttonText = if (selectedFiles.isNotEmpty()) "스크랩 기반 글 생성 (${selectedFiles.size}개 파일)" else "직접 작성 기반 글 생성"; Text(buttonText) } - } - } - if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } - } -} - -@Composable -fun LogTab(logs: List) { - TextField(value = logs.joinToString("\n"), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize().padding(8.dp)) -} - -@Composable -fun ResultTab(result: String) { - Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { Text(result) } -} fun main() = application { val imageLoader = ImageLoader.Builder(coil3.PlatformContext.INSTANCE) @@ -457,7 +65,7 @@ fun main() = application { .build() Window( onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() }, - title = "자동 블로그 포스팅 도우미 v9.0 (Context-Aware)" + title = "자동 블로그 포스팅 도우미 v14.0 (Contextual Footer)" // ⭐️ [수정] 버전명 변경 ) { App(imageLoader) } diff --git a/src/main/kotlin/core/BrowserManager.kt b/src/main/kotlin/core/BrowserManager.kt new file mode 100644 index 0000000..a748e5f --- /dev/null +++ b/src/main/kotlin/core/BrowserManager.kt @@ -0,0 +1,27 @@ +// core/BrowserManager.kt +package core + +import org.openqa.selenium.WebDriverException +import org.openqa.selenium.chrome.ChromeDriver +import org.openqa.selenium.chrome.ChromeOptions + +object BrowserManager { + private var driver: ChromeDriver? = null + + fun getChromeDriver(options: ChromeOptions): ChromeDriver { + try { + driver?.title // Check if the driver is still active + } catch (e: WebDriverException) { + driver = null // Driver is dead, so nullify it + } + if (driver == null) { + driver = ChromeDriver(options) + } + return driver!! + } + + fun quitChromeDriver() { + driver?.quit() + driver = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/core/FileManager.kt b/src/main/kotlin/core/FileManager.kt new file mode 100644 index 0000000..9a859f2 --- /dev/null +++ b/src/main/kotlin/core/FileManager.kt @@ -0,0 +1,29 @@ +// core/FileManager.kt +package core + +import kotlinx.serialization.encodeToString +import models.ScrapedData +import utils.Global +import utils.logMessage +import java.io.File + +object FileManager { + + fun saveDataToJsonFile(keyword: String, data: ScrapedData, folderPath: String, logs: MutableList): File { + val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() } + val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "") + val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json" + val file = File(directory, fileName) + file.writeText(Global.jsonParser.encodeToString(data)) + logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.") + return file + } + + fun loadScrapedJsonFiles(folderPath: String, logs: MutableList): List { + logMessage(logs, "'$folderPath'에서 파일 목록 로딩...") + return File(folderPath).listFiles { _, name -> name.endsWith(".json") } + ?.sortedByDescending { it.lastModified() } + .orEmpty() + .also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") } + } +} \ No newline at end of file diff --git a/src/main/kotlin/core/LlmApiService.kt b/src/main/kotlin/core/LlmApiService.kt new file mode 100644 index 0000000..6ac8a90 --- /dev/null +++ b/src/main/kotlin/core/LlmApiService.kt @@ -0,0 +1,195 @@ +// core/LlmApiService.kt +package core + +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.* +import kotlinx.coroutines.flow.MutableStateFlow +import models.* +import utils.Global +import utils.Global.httpClient +import utils.Global.jsonParser +import utils.logMessage +import java.io.File +import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException + +object LlmApiService { + + suspend fun uploadFiles(files: List, apiKey: String, workspaceSlug: String, logs: MutableList): List { + logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...") + val uploadedDocIds = mutableListOf() + files.forEach { file -> + logMessage(logs, " - '${file.name}' 업로드 중...") + try { + val response: HttpResponse = Global.httpClient.post("http://localhost:3001/api/v1/document/upload") { + header("Authorization", "Bearer $apiKey") + setBody(MultiPartFormDataContent(formData { + append("addToWorkspaces", workspaceSlug) + val scrapedData = Global.jsonParser.decodeFromString(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.documents.firstOrNull()?.id?.let { uploadedDocIds.add(it); logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).") } ?: 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 + } + + suspend fun generateBlogPost( + scrapedDataList: List, + userOwnContent: String, + allSelectedImages: List, + userDirection: String, + userScrapComment: String, + userMainTopic: String, + apiKey: String, + workspaceSlug: String, + logs: MutableList + ): String { + logMessage(logs, "LLM 블로그 글 생성 요청...") + + val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) { + scrapedDataList.mapIndexed { index, data -> "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" }.joinToString("\n").let { "--- 참고자료 ---\n$it" } + } else { + "--- 주요 내용 ---\n아래 내용을 바탕으로 글을 작성해주세요.\n$userOwnContent" + } + + val commentPromptPart = if (userScrapComment.isNotBlank()) { + "--- 작성자 코멘트 ---\n$userScrapComment\n이 코멘트의 논조와 생각을 반영하여 글을 작성해주세요.\n" + } else "" + + val mainTopicPromptPart = if (userMainTopic.isNotBlank()) { + "--- 글의 핵심 주제 ---\n$userMainTopic\n이 주제가 글의 중심 내용이 되도록 작성해주세요.\n" + } else "" + + val imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n")}" else "" + + val finalPrompt = """ + 당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. + $contentSourcePromptPart + $commentPromptPart + $mainTopicPromptPart + --- 요청사항 --- + 1. 사용자 요청: "$userDirection" + 2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요. + 3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요. + $imagePromptPart + 5. 가능한 자연스럽고 유머러스하게 글을 작성해주세요. + 6. 주제에 벗어나지 않고 해당 주제에 포커스를 맞춰서 글을 작성해주세요. + """.trimIndent() + + try { + val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") { + header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt)) + } + val chatResponse = jsonParser.decodeFromString(response.bodyAsText()) + logMessage(logs, "✅ LLM 블로그 글 생성 완료.") + return chatResponse.textResponse + } catch (e: Exception) { + logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}"); return "블로그 글 생성 실패: ${e.message}" + } + } + + suspend fun reviseBlogPost( + currentPost: String, + revisionRequest: String, + apiKey: String, + workspaceSlug: String, + logs: MutableList + ): String { + logMessage(logs, "LLM 블로그 글 수정 요청...") + if (revisionRequest.isBlank()) { + logMessage(logs, "⚠️ 수정 요청사항이 비어있어 수정을 중단합니다.") + return currentPost + } + + val finalPrompt = """ + 당신은 주어진 글을 사용자의 요청에 맞게 수정하는 전문 편집자입니다. 아래의 원본 글을 수정 요청사항에 따라 개선해주세요. 원본의 주제와 주요 내용은 유지하되, 요청을 충실히 반영하여 더 나은 글로 만들어주세요. 마크다운 형식은 유지해주세요. + + --- 원본 글 --- + $currentPost + + --- 수정 요청사항 --- + $revisionRequest + + --- 수정된 글 --- + """.trimIndent() + + try { + val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") { + header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt)) + } + val chatResponse = jsonParser.decodeFromString(response.bodyAsText()) + logMessage(logs, "✅ LLM 블로그 글 수정 완료.") + return chatResponse.textResponse + } catch (e: Exception) { + logMessage(logs, "❌ LLM 수정 호출 중 오류: ${e.message}"); return "블로그 글 수정 실패: ${e.message}" + } + } + + suspend fun cleanupWorkspace(docIds: List, apiKey: String, logs: MutableList) { + if (docIds.isEmpty()) return + logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...") + try { + 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}") } + } + + suspend fun analyzeReceipts( + files: List, + apiKey: String, + receiptWorkspaceSlug: String, + receiptContext: String, + logs: MutableList, + resultState: MutableStateFlow + ) { + if (apiKey.isBlank() || receiptWorkspaceSlug.isBlank() || files.isEmpty()) { + val missing = listOfNotNull(if (apiKey.isBlank()) "API Key" else null, if (receiptWorkspaceSlug.isBlank()) "영수증 Workspace" else null, if (files.isEmpty()) "영수증 파일" else null).joinToString(); logs.add("⚠️ 영수증 분석을 시작할 수 없습니다. ($missing 누락)"); return + } + logMessage(logs, "Starting receipt analysis with stream-chat mode...") + resultState.value = "영수증 분석 중..." + try { + val attachments = files.map { file -> val base64Image = Base64.getEncoder().encodeToString(file.readBytes()); val mimeType = when (file.extension.lowercase()) { "png" -> "image/png"; "jpeg", "jpg" -> "image/jpeg"; else -> "application/octet-stream" }; Attachment(file.name, mimeType, "data:$mimeType;base64,$base64Image") } + val basePrompt = "첨부된 영수증 이미지들을 분석해줘." + val finalPrompt = if (receiptContext.isNotBlank()) """$basePrompt --- 추가 정보 --- $receiptContext --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘.""" else basePrompt + logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}") + val requestBody = StreamChatRequest(message = finalPrompt, attachments = attachments, mode = "chat", sessionId = "receipt-analysis-${System.currentTimeMillis()}") + httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") { + accept(ContentType.Application.Json); header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(requestBody) + }.execute { response -> + if (response.status.isSuccess()) { + val channel: ByteReadChannel = response.bodyAsChannel() + var fullResponseText = "" + while (!channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: continue + if (line.startsWith("data:")) { + val jsonChunk = line.removePrefix("data:").trim() + if (jsonChunk.isEmpty() || jsonChunk == "[DONE]") continue + try { + when (val streamObject = jsonParser.decodeFromString(jsonChunk)) { + is SealedLLMStreamResponse.TextResponseChunk -> { val textChunk = streamObject.textResponse ?: ""; if (textChunk.isNotBlank() && textChunk != "-") { fullResponseText += textChunk; resultState.value = fullResponseText } } + is SealedLLMStreamResponse.FinalizeResponseStream -> { logMessage(logs, "✅ Stream finalized."); streamObject.metrics?.let { logs.add(" - Metrics: total_tokens=${it.totalTokens}, duration=${it.duration}s") }; break } + is SealedLLMStreamResponse.AbortResponse -> { logMessage(logs, "⚠️ Stream aborted by server. Reason: ${streamObject.textResponse}"); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break } + } + } catch (e: Exception) { logMessage(logs, "❌ Chunk parsing error: ${e.message} | Chunk: $jsonChunk") } + } + } + logMessage(logs, "Receipt analysis stream processing finished.") + } else { val errorBody = response.bodyAsText(); logMessage(logs, "❌ Error during receipt analysis: ${response.status} - $errorBody"); resultState.value = "API 오류: ${response.status}" } + } + } catch (e: CancellationException) { throw e } + catch (e: Exception) { logMessage(logs, "❌ Exception in receipt analysis: ${e.message}"); resultState.value = e.message ?: "알 수 없는 오류" } + } +} \ No newline at end of file diff --git a/src/main/kotlin/core/ScrapingService.kt b/src/main/kotlin/core/ScrapingService.kt new file mode 100644 index 0000000..be78656 --- /dev/null +++ b/src/main/kotlin/core/ScrapingService.kt @@ -0,0 +1,65 @@ +// core/ScrapingService.kt +package core + +import models.SearchResult +import models.ScrapedData +import org.jsoup.Jsoup +import org.openqa.selenium.By +import org.openqa.selenium.chrome.ChromeOptions +import utils.logMessage + +object ScrapingService { + + suspend fun fetchGoogleTrends(logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): List { + logMessage(logs, "Google Trends 페이지 스크랩 시작...") + val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR" + val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") } + val currentDriver = BrowserManager.getChromeDriver(options) + return try { + currentDriver.get(trendsUrl) + Thread.sleep(5000) + currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")).mapNotNull { it.text.takeIf(String::isNotBlank) }.also { logMessage(logs, "✅ Google Trends 키워드 ${it.size}개 스크랩 완료.") } + } catch (e: Exception) { + logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}"); BrowserManager.quitChromeDriver(); emptyList() + } finally { if (!keepSession) BrowserManager.quitChromeDriver() } + } + + suspend fun searchOnGoogle(keyword: String, logs: MutableList, isBrowserVisible: Boolean, keepSession: Boolean): List { + logMessage(logs, "'$keyword' 키워드로 Google 검색 시작...") + val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") } + val currentDriver = BrowserManager.getChromeDriver(options) + val results = mutableListOf() + try { + currentDriver.get("https://www.google.com/search?q=$keyword") + Thread.sleep(10000) + currentDriver.findElements(By.cssSelector("div[data-rpos]")).take(10).forEach { element -> + try { + val title = element.findElement(By.cssSelector("h3")).text + val url = element.findElement(By.cssSelector("a")).getAttribute("href") + if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url)) + } catch (e: Exception) { /* 개별 오류 무시 */ } + } + logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.") + } catch (e: Exception) { + logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); BrowserManager.quitChromeDriver() + } finally { if (!keepSession) BrowserManager.quitChromeDriver() } + return results + } + + 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"); addArguments("--disable-gpu") } + val currentDriver = BrowserManager.getChromeDriver(options) + return try { + currentDriver.get(url) + Thread.sleep(2000) + val doc = Jsoup.parse(currentDriver.pageSource) + val articleContent = doc.select("#app, .app, article, .article-body, #article_body, .news-article-body-view").text() + val allImages = doc.select("article img, .article-body img, #article_body img, .news-article-body-view img").map { it.absUrl("src") }.filter { it.isNotBlank() && it.startsWith("http") }.distinct() + if (articleContent.isBlank()) { logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다."); null } + else { logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)"); ScrapedData(sourceUrl = url, selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent) } + } catch (e: Exception) { + logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}"); BrowserManager.quitChromeDriver(); null + } finally { if (!keepSession) BrowserManager.quitChromeDriver() } + } +} \ No newline at end of file diff --git a/src/main/kotlin/models/ApiData.kt b/src/main/kotlin/models/ApiData.kt new file mode 100644 index 0000000..983cf5c --- /dev/null +++ b/src/main/kotlin/models/ApiData.kt @@ -0,0 +1,36 @@ +// models/ApiData.kt +package models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator + +// --- Stream Chat API 응답 모델 --- +@Serializable +@JsonClassDiscriminator("type") +sealed interface SealedLLMStreamResponse { + @Serializable + @SerialName("textResponseChunk") + data class TextResponseChunk(val uuid: String, val textResponse: String?, val sources: List = emptyList(), val close: Boolean, val error: Boolean) : SealedLLMStreamResponse + + @Serializable + @SerialName("finalizeResponseStream") + data class FinalizeResponseStream(val uuid: String, val chatId: Int, val metrics: Metrics?, val close: Boolean, val error: Boolean) : SealedLLMStreamResponse + + @Serializable + @SerialName("abort") + data class AbortResponse(val uuid: String? = null, val textResponse: String? = null, val close: Boolean = true, val error: Boolean? = null) : SealedLLMStreamResponse +} + +@Serializable data class Source(val id: String? = null, val text: String? = null, val location: String? = null, val distance: Float? = null, val score: Float? = null) +@Serializable data class Metrics(@SerialName("completion_tokens") val completionTokens: Int, @SerialName("prompt_tokens") val promptTokens: Int, @SerialName("total_tokens") val totalTokens: Int, val outputTps: Double? = null, val duration: Double? = null) + +// --- 일반 Chat 및 문서 관리 API 모델 --- +@Serializable data class AnythingLLMChatRequest(val message: String) +@Serializable data class AnythingLLMChatResponse(val textResponse: String) +@Serializable data class UploadResponse(val documents: List) { @Serializable data class Document(val id: String, val location: String) } +@Serializable data class DeleteDocumentsRequest(val deletes: List) + +// --- Stream Chat API 요청 모델 --- +@Serializable data class Attachment(val name: String, val mime: String, @SerialName("contentString") val content: String) +@Serializable data class StreamChatRequest(val message: String, val attachments: List, val mode: String, val sessionId: String) \ No newline at end of file diff --git a/src/main/kotlin/models/AppData.kt b/src/main/kotlin/models/AppData.kt new file mode 100644 index 0000000..fb30812 --- /dev/null +++ b/src/main/kotlin/models/AppData.kt @@ -0,0 +1,14 @@ +// models/AppData.kt +package models + +import kotlinx.serialization.Serializable + +data class SearchResult(val title: String, val url: String) + +@Serializable +data class ScrapedData( + val sourceUrl: String, + val selectedImageUrls: List, + val allImageUrls: List, + val content: String +) \ No newline at end of file diff --git a/src/main/kotlin/ui/App.kt b/src/main/kotlin/ui/App.kt new file mode 100644 index 0000000..1580406 --- /dev/null +++ b/src/main/kotlin/ui/App.kt @@ -0,0 +1,266 @@ +// ui/App.kt +package ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import core.FileManager +import core.FileManager.loadScrapedJsonFiles +import core.FileManager.saveDataToJsonFile +import core.LlmApiService +import core.LlmApiService.analyzeReceipts +import core.LlmApiService.cleanupWorkspace +import core.LlmApiService.generateBlogPost +import core.LlmApiService.reviseBlogPost +import core.LlmApiService.uploadFiles +import core.ScrapingService +import core.ScrapingService.fetchGoogleTrends +import core.ScrapingService.scrapeArticleByUrl +import core.ScrapingService.searchOnGoogle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import models.ScrapedData +import models.SearchResult +import ui.tabs.* +import ui.widgets.FileDialog +import utils.Global.jsonParser +import utils.logMessage +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.File +import java.util.prefs.Preferences +import kotlin.coroutines.cancellation.CancellationException +import kotlin.text.isBlank + +// (기존 App Composable의 상태 변수 선언 및 로직을 여기에 모두 이동) +private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java) +const val PREF_FOLDER_PATH = "folder_path" +const val PREF_API_KEY = "api_key" +const val PREF_WORKSPACE_SLUG = "workspace_slug" +const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" +const val PREF_MODEL_NAME = "model_name" + +// --- UI 컴포넌트 --- +@Composable +fun App(imageLoader: ImageLoader) { + val coroutineScope = rememberCoroutineScope() + var tabIndex by remember { mutableStateOf(0) } + val tabs = listOf("스크랩 기반 포스팅", "직접 포스팅", "영수증 분석기", "통신 로그", "블로그 결과") + var keywords by remember { mutableStateOf>(emptyList()) } + var searchResults by remember { mutableStateOf>(emptyList()) } + var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") } + var blogPostFooter by remember { mutableStateOf("") } + val logMessages = remember { mutableStateListOf() } + var isLoading by remember { mutableStateOf(false) } + var selectedKeyword by remember { mutableStateOf("") } + var userPrompt by remember { mutableStateOf("개인 기록용으로 가볍게 남기는 스타일로 작성해줘.") } + var isBrowserVisible by remember { mutableStateOf(true) } + var keepBrowserSession by remember { mutableStateOf(true) } + 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-workspace")) } + var receiptWorkspaceSlug by remember { mutableStateOf(prefs.get(PREF_RECEIPT_WORKSPACE_SLUG, "receipts")) } + var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "Llama-3.1-8B-Vision")) } + var scrapedFiles by remember { mutableStateOf>(emptyList()) } + var selectedFiles by remember { mutableStateOf>(emptySet()) } + var currentlyOpenFile by remember { mutableStateOf(null) } + var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") } + var imagesForSelection by remember { mutableStateOf>(emptyList()) } + var currentSelectedImages by remember { mutableStateOf>(emptySet()) } + var manualKeyword by remember { mutableStateOf("") } + var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다.") } + var isImageUploadDialogVisible by remember { mutableStateOf(false) } + var uploadedImageFiles by remember { mutableStateOf>(emptyList()) } + var receiptFiles by remember { mutableStateOf>(emptyList()) } + var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") } + var isReceiptDialogVisible by remember { mutableStateOf(false) } + var analysisJob by remember { mutableStateOf(null) } + var receiptContextPrompt by remember { mutableStateOf("") } + var userScrapComment by remember { mutableStateOf("") } + var userMainTopic by remember { mutableStateOf("") } + var revisionRequest by remember { mutableStateOf("") } + + LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages); prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) } + LaunchedEffect(apiKey) { prefs.put(PREF_API_KEY, apiKey) } + LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) } + LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) } + LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) } + + if (isImageUploadDialogVisible) { FileDialog { selected -> isImageUploadDialogVisible = false; if (selected.isNotEmpty()) { uploadedImageFiles = uploadedImageFiles + selected; logMessage(logMessages, "✅ 개인 이미지 파일 ${selected.size}개 추가됨.") } } } + if (isReceiptDialogVisible) { FileDialog { selected -> isReceiptDialogVisible = false; if (selected.isNotEmpty()) { receiptFiles = receiptFiles + selected; logMessage(logMessages, "🧾 영수증 이미지 ${selected.size}개 추가됨.") } } } + + MaterialTheme { + Column(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it }); Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f)); Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it }); Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f)) } + 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)) + Row(Modifier.fillMaxWidth()) { OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("블로그용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true); Spacer(Modifier.width(4.dp)); OutlinedTextField(value = receiptWorkspaceSlug, onValueChange = { receiptWorkspaceSlug = it }, label = { Text("영수증 처리용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true) } + Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("사용 중인 LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + } + Divider() + TabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } } + + when (tabIndex) { + 0 -> ScrapBasedPostTab( + isLoading, keywords, searchResults, scrapedFiles, selectedFiles, viewedFileContent, imagesForSelection, + currentSelectedImages, userPrompt, imageLoader, manualKeyword, userScrapComment, userMainTopic, + onManualKeywordChange = { manualKeyword = it }, onUserPromptChange = { userPrompt = it }, onUserScrapCommentChange = { userScrapComment = it }, onUserMainTopicChange = { userMainTopic = it }, + onFetchTrends = { coroutineScope.launch(Dispatchers.IO) { isLoading = true; keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } }, + onKeywordSelect = { keyword -> selectedKeyword = keyword; manualKeyword = keyword; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } }, + onSearchResultSelect = { result -> coroutineScope.launch(Dispatchers.IO) { isLoading = true; scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)?.let { data -> val finalKeyword = if (selectedKeyword.isNotBlank()) selectedKeyword else manualKeyword; val savedFile = saveDataToJsonFile(finalKeyword, data, scrapedFolderPath,logMessages); scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages); currentlyOpenFile = savedFile; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() }; isLoading = false } }, + onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages) } }, + onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file }, + onFileView = { file -> coroutineScope.launch(Dispatchers.IO) { try { val data = jsonParser.decodeFromString(file.readText()); currentlyOpenFile = file; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") } } }, + onImageSelect = { imageUrl -> currentSelectedImages = if (imageUrl in currentSelectedImages) currentSelectedImages - imageUrl else currentSelectedImages + imageUrl }, + onSaveChanges = { coroutineScope.launch(Dispatchers.IO) { currentlyOpenFile?.let { file -> try { val originalData = jsonParser.decodeFromString(file.readText()); val updatedData = originalData.copy(selectedImageUrls = currentSelectedImages.toList()); file.writeText(jsonParser.encodeToString(updatedData)); logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.") } catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") } } } }, + onGeneratePost = { + if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@ScrapBasedPostTab } + if (selectedFiles.isEmpty()) { logMessage(logMessages, "⚠️ 글을 생성할 스크랩 파일을 1개 이상 선택해주세요."); return@ScrapBasedPostTab } + coroutineScope.launch(Dispatchers.IO) { + isLoading = true + try { + val selectedDataList = selectedFiles.map { jsonParser.decodeFromString(it.readText()) } + val allImages = selectedDataList.flatMap { it.selectedImageUrls }.distinct() + val uploadedDocIds = uploadFiles(selectedFiles.toList(), apiKey, workspaceSlug,logMessages) + if (uploadedDocIds.isEmpty()) { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다."); return@launch } + + val resultText = generateBlogPost(selectedDataList, "", allImages, userPrompt, userScrapComment, userMainTopic, apiKey, workspaceSlug,logMessages) + + // ⭐️ [수정] 컨텍스트를 포함한 꼬리말 생성 + val footer = buildString { + appendLine("\n\n---") + if (selectedDataList.isNotEmpty()) { + appendLine("- 원문 출처:") + selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") } + } + if (allImages.isNotEmpty()) { + appendLine("\n- 사용된 이미지:") + allImages.forEach { appendLine(" - $it") } + } + + appendLine("\n\n[이 글의 작성 과정]") + val contextSummary = mutableListOf() + if (userMainTopic.isNotBlank()) contextSummary.add("주제: '${userMainTopic}'") + if (userScrapComment.isNotBlank()) contextSummary.add("작성자 코멘트: '${userScrapComment.take(30)}...'") + if (userPrompt.isNotBlank()) contextSummary.add("요청 스타일: '${userPrompt.take(30)}...'") + + append("이 포스팅은 ") + if (contextSummary.isNotEmpty()) append("${contextSummary.joinToString(", ")} 등의 정보를 바탕으로, ") + append("여러 참고 자료를 종합하여 ${modelName} AI 모델이 초안을 생성했습니다. 이후 작성자의 검토를 거쳐 수정 및 발행되었습니다.") + } + + blogPostResult = resultText + blogPostFooter = footer + tabIndex = 4 + cleanupWorkspace(uploadedDocIds, apiKey,logMessages) + } finally { isLoading = false } + } + } + ) + 1 -> DirectPostTab( + isLoading = isLoading, + userOwnContent = userOwnContent, + onUserOwnContentChange = { userOwnContent = it }, + uploadedImageFiles = uploadedImageFiles, + imageLoader = imageLoader, + userPrompt = userPrompt, + onUserPromptChange = { userPrompt = it }, + onUploadImage = { isImageUploadDialogVisible = true }, + onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove }, + onGeneratePost = { + if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@DirectPostTab } + if (userOwnContent.isBlank() || uploadedImageFiles.isEmpty()) { logMessage(logMessages, "⚠️ 직접 포스팅을 하려면 내용과 이미지가 모두 필요합니다."); return@DirectPostTab } + coroutineScope.launch(Dispatchers.IO) { + isLoading = true + try { + val allImages = uploadedImageFiles.map { it.toURI().toString() } + val resultText = generateBlogPost(emptyList(), userOwnContent, allImages, userPrompt, "", "", apiKey, workspaceSlug,logMessages) + + // ⭐️ [수정] 컨텍스트를 포함한 꼬리말 생성 (직접 포스팅용) + val footer = buildString { + appendLine("\n\n---") + if (allImages.isNotEmpty()) { + appendLine("- 사용된 이미지:") + allImages.forEach { appendLine(" - $it") } + } + + appendLine("\n\n[이 글의 작성 과정]") + append("이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ") + if (userPrompt.isNotBlank()) append("'${userPrompt.take(30)}...' 스타일로 문체와 구성을 다듬도록 요청하여, ") + append("${modelName} AI 모델의 도움을 받아 완성되었습니다.") + } + + blogPostResult = resultText + blogPostFooter = footer + tabIndex = 4 + } finally { isLoading = false } + } + } + ) + 2 -> ReceiptAnalyzerTab( + isLoading, receiptFiles, receiptAnalysisResult, isAnalyzing = analysisJob?.isActive == true, + receiptContextPrompt, imageLoader, + onUploadReceipt = { isReceiptDialogVisible = true }, + onRemoveReceipt = { file -> receiptFiles = receiptFiles - file }, + onReceiptContextPromptChange = { receiptContextPrompt = it }, + onAnalyzeReceipts = { + analysisJob = coroutineScope.launch(Dispatchers.IO) { + try { + isLoading = true + val resultFlow = MutableStateFlow("") + val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } } + analyzeReceipts(receiptFiles, apiKey, receiptWorkspaceSlug, receiptContextPrompt,logMessages, resultFlow) + uiUpdateJob.cancel() + } catch (e: CancellationException) { logMessage(logMessages, "ℹ️ 영수증 분석이 사용자에 의해 중단되었습니다."); receiptAnalysisResult = "분석이 중단되었습니다." + } catch (e: Exception) { logMessage(logMessages, "❌ 영수증 분석 중 심각한 오류 발생: ${e.message}"); receiptAnalysisResult = "오류 발생: ${e.message}" + } finally { isLoading = false; analysisJob = null } + } + }, + onCancelAnalysis = { analysisJob?.cancel() } + ) + 3 -> LogTab(logMessages) + 4 -> ResultTab( + result = blogPostResult, + onRequestResultChange = { blogPostResult = it }, + revisionRequest = revisionRequest, + onRevisionRequestChange = { revisionRequest = it }, + isLoading = isLoading, + onRevise = { + if (apiKey.isBlank() || workspaceSlug.isBlank()) { + logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요.") + return@ResultTab + } + coroutineScope.launch(Dispatchers.IO) { + isLoading = true + try { + val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug,logMessages) + blogPostResult = revisedText + revisionRequest = "" + } finally { + isLoading = false + } + } + }, + onCopyToClipboard = { + val fullContent = blogPostResult + blogPostFooter + val stringSelection = StringSelection(fullContent) + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(stringSelection, null) + logMessage(logMessages, "✅ 블로그 전체 내용(꼬리말 포함)이 클립보드에 복사되었습니다.") + } + ) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/tabs.kt b/src/main/kotlin/ui/tabs.kt new file mode 100644 index 0000000..f33a24e --- /dev/null +++ b/src/main/kotlin/ui/tabs.kt @@ -0,0 +1,319 @@ +// ui/tabs/ScrapBasedPostTab.kt +package ui.tabs + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +// (필요한 import 추가) +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import coil3.compose.rememberAsyncImagePainter +import io.ktor.client.request.url +import models.SearchResult +import java.io.File + + +@Composable +fun ScrapBasedPostTab( + isLoading: Boolean, keywords: List, searchResults: List, scrapedFiles: List, selectedFiles: Set, + viewedFileContent: String, imagesForSelection: List, currentSelectedImages: Set, + userPrompt: String, imageLoader: ImageLoader, manualKeyword: String, userScrapComment: String, userMainTopic: String, + onManualKeywordChange: (String) -> Unit, onUserPromptChange: (String) -> Unit, onUserScrapCommentChange: (String) -> Unit, onUserMainTopicChange: (String) -> Unit, + onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit, + onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit, onFileView: (File) -> Unit, + onImageSelect: (String) -> Unit, onSaveChanges: () -> Unit, onGeneratePost: () -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxSize()) { + // 1. 키워드 및 검색 + Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text("키워드 직접 입력") }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading) + Spacer(Modifier.width(4.dp)) + Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text("검색") } + } + Spacer(Modifier.height(8.dp)) + Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") } + Divider(modifier = Modifier.padding(vertical = 8.dp)) + LazyColumn(modifier = Modifier.weight(1f)) { + items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) } + } + } + // 2. 검색 결과 및 스크랩 + Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(4.dp)) { + Text("검색 결과 (클릭하여 스크랩)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(searchResults) { result -> + Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) { + Text(result.title, style = MaterialTheme.typography.subtitle1, color = MaterialTheme.colors.primary) + Text(result.url, style = MaterialTheme.typography.caption, maxLines = 1) + } + } + } + } + // 3. 파일 관리 및 생성 + Column(modifier = Modifier.weight(3f).padding(horizontal = 4.dp).verticalScroll(rememberScrollState())) { + // 저장된 파일 + Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("저장된 파일", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp)) + Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") } + } + Box(modifier = Modifier.heightIn(max = 200.dp)) { + LazyColumn { + items(scrapedFiles) { file -> + Row(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp), 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) + } + } + } + } + Spacer(Modifier.height(8.dp)) + Text("파일 내용", style = MaterialTheme.typography.subtitle1) + Text(text = viewedFileContent, modifier = Modifier.height(100.dp).fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp).verticalScroll(rememberScrollState()), style = MaterialTheme.typography.body2) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("대표 이미지 선택 (다중 가능)", style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f)) + Button(onClick = onSaveChanges, enabled = !isLoading && imagesForSelection.isNotEmpty()) { Text("선택 이미지 저장") } + } + LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) { + items(imagesForSelection) { imageUrl -> + val isSelected = imageUrl in currentSelectedImages + 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(16.dp)) + + // 글 생성 제어 + Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(8.dp)) { + Text("LLM 요청사항", style = MaterialTheme.typography.h6) + OutlinedTextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") }) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = userMainTopic, + onValueChange = onUserMainTopicChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("글의 핵심 주제 (예: 2025년 최신 IT 트렌드)") }, + singleLine = true + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField(value = userScrapComment, onValueChange = onUserScrapCommentChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text("작성자 코멘트 (스크랩한 내용에 대한 당신의 생각)") }) + Spacer(Modifier.height(8.dp)) + Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { + Text("선택한 파일(${selectedFiles.size}개)로 글 생성") + } + } + } + } + if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } + } +} + + +@Composable +fun DirectPostTab( + isLoading: Boolean, + userOwnContent: String, + onUserOwnContentChange: (String) -> Unit, + uploadedImageFiles: List, + imageLoader: ImageLoader, + userPrompt: String, + onUserPromptChange: (String) -> Unit, + onUploadImage: () -> Unit, + onRemoveUploadedImage: (File) -> Unit, + onGeneratePost: () -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxSize().padding(8.dp)) { + // 1. 내용 작성 + Column(modifier = Modifier.weight(2f).padding(end = 8.dp)) { + Text("직접 작성 (여행 기록, 정보 공유 등)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) + OutlinedTextField( + value = userOwnContent, + onValueChange = onUserOwnContentChange, + modifier = Modifier.fillMaxSize(), + label = { Text("블로그에 올릴 내용을 직접 작성하세요.") } + ) + } + + // 2. 이미지 및 생성 제어 + Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(8.dp)) { + Text("이미지 업로드", style = MaterialTheme.typography.h6) + Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("내 PC에서 이미지 업로드") } + Spacer(Modifier.height(8.dp)) + LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { + items(uploadedImageFiles) { file -> + Box(modifier = Modifier.padding(4.dp)) { + Image( + painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader), + contentDescription = "Uploaded Image", + modifier = Modifier.size(100.dp).clickable { onRemoveUploadedImage(file) }, + contentScale = ContentScale.Crop + ) + } + } + } + Spacer(Modifier.height(16.dp)) + + Text("LLM 요청사항", style = MaterialTheme.typography.h6) + OutlinedTextField( + value = userPrompt, + onValueChange = onUserPromptChange, + modifier = Modifier.fillMaxWidth().height(150.dp), + label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") } + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = onGeneratePost, + enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(), + modifier = Modifier.fillMaxWidth() + ) { Text("작성한 내용으로 글 생성") } + } + } + if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } + } +} + + +@Composable +fun ReceiptAnalyzerTab( + isLoading: Boolean, + receiptFiles: List, + receiptAnalysisResult: String, + isAnalyzing: Boolean, + receiptContextPrompt: String, + imageLoader: ImageLoader, + onUploadReceipt: () -> Unit, + onRemoveReceipt: (File) -> Unit, + onReceiptContextPromptChange: (String) -> Unit, + onAnalyzeReceipts: () -> Unit, + onCancelAnalysis: () -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text("영수증 분석기", style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 8.dp)) + Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("영수증 이미지 업로드") } + Spacer(Modifier.height(8.dp)) + LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { + items(receiptFiles) { file -> + Box(modifier = Modifier.padding(4.dp)) { + Image( + painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader), + contentDescription = "Receipt Image", + modifier = Modifier.size(100.dp).clickable { onRemoveReceipt(file) }, + contentScale = ContentScale.Crop + ) + } + } + } + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = receiptContextPrompt, + onValueChange = onReceiptContextPromptChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("추가 정보를 입력하면 더 정확하게 분석할 수 있습니다.") }, + label = { Text("추가 정보 입력 (예: 부산 출장 경비)") } + ) + Spacer(Modifier.height(16.dp)) + if (isAnalyzing) { + Button(onClick = onCancelAnalysis, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text("분석 중단하기") + } + } + } else { + Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { + Text("선택한 영수증 분석 시작 (${receiptFiles.size}개)") + } + } + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = receiptAnalysisResult, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxSize(), + label = { Text("분석 결과 (내용 복사하여 사용)") } + ) + } + if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } + } +} + + +@Composable +fun LogTab(logs: List) { + TextField(value = logs.joinToString("\n"), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize().padding(8.dp)) +} + +@Composable +fun ResultTab( + result: String, + onRequestResultChange: (String) -> Unit, + revisionRequest: String, + onRevisionRequestChange: (String) -> Unit, + isLoading: Boolean, + onRevise: () -> Unit, + onCopyToClipboard: () -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + OutlinedTextField( + value = result, + onValueChange = onRequestResultChange, + modifier = Modifier.weight(1f).fillMaxWidth(), + label = { Text("블로그 글 결과 (LLM 생성 본문)") } + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = revisionRequest, + onValueChange = onRevisionRequestChange, + modifier = Modifier.fillMaxWidth().height(100.dp), + label = { Text("추가 요청사항") }, + placeholder = { Text("예: 문체를 좀 더 전문적으로 바꿔줘. 1번 항목을 더 자세히 설명해줘.") }, + enabled = !isLoading + ) + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = onRevise, + enabled = !isLoading && revisionRequest.isNotBlank(), + modifier = Modifier.weight(1f) + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp) + } else { + Text("LLM으로 글 보완하기") + } + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = onCopyToClipboard, + enabled = !isLoading && result.isNotBlank(), + modifier = Modifier.weight(1f) + ) { + Text("전체 내용 클립보드에 복사") + } + } + } + } +} diff --git a/src/main/kotlin/ui/widgets/FileDialog.kt b/src/main/kotlin/ui/widgets/FileDialog.kt new file mode 100644 index 0000000..350657e --- /dev/null +++ b/src/main/kotlin/ui/widgets/FileDialog.kt @@ -0,0 +1,22 @@ +// ui/widgets/FileDialog.kt +package ui.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import java.awt.FileDialog +import java.awt.Frame +import java.io.File + +@Composable +fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List) -> Unit) { + val fileDialog = remember { + FileDialog(parent, "파일 선택", FileDialog.LOAD).apply { + isMultipleMode = true + } + } + LaunchedEffect(Unit) { + fileDialog.isVisible = true + onCloseRequest(fileDialog.files.toList()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/utils/Global.kt b/src/main/kotlin/utils/Global.kt new file mode 100644 index 0000000..0dff33d --- /dev/null +++ b/src/main/kotlin/utils/Global.kt @@ -0,0 +1,33 @@ +// utils/Global.kt +package utils + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.* +import java.util.prefs.Preferences + +object Global { + val httpClient = HttpClient(CIO) { + install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) } + install(HttpTimeout) { requestTimeoutMillis = 900000 } + } + + val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = true } + val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java) + + const val PREF_FOLDER_PATH = "folder_path" + const val PREF_API_KEY = "api_key" + const val PREF_WORKSPACE_SLUG = "workspace_slug" + const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" + const val PREF_MODEL_NAME = "model_name" +} + +fun logMessage(logs: MutableList, message: String) { + val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) + logs.add(0, "$timestamp: $message") +} \ No newline at end of file