From c016328f1ddabc88fc1ce721bfd62f56f622bb71 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Thu, 2 Oct 2025 16:09:23 +0900 Subject: [PATCH] .... --- src/main/kotlin/Main.kt | 575 ++++++++++++++++++++++++++++++++-------- 1 file changed, 470 insertions(+), 105 deletions(-) diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 98da828..9316861 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -17,9 +17,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -// ⭐️ [수정] Coil 3 import 경로 수정 import coil3.ImageLoader -//import coil3.compose.LocalImageLoader import coil3.compose.rememberAsyncImagePainter import coil3.network.okhttp.OkHttpNetworkFetcherFactory import io.ktor.client.* @@ -33,7 +31,10 @@ import io.ktor.client.request.forms.formData import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readUTF8Line import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -43,6 +44,8 @@ import org.openqa.selenium.By import org.openqa.selenium.WebDriverException import org.openqa.selenium.chrome.ChromeDriver import org.openqa.selenium.chrome.ChromeOptions +import java.awt.FileDialog +import java.awt.Frame import java.io.File import java.text.SimpleDateFormat import java.util.* @@ -50,14 +53,33 @@ import java.util.prefs.Preferences // --- 데이터 클래스 정의 --- @Serializable data class AnythingLLMChatRequest(val message: String) -@Serializable data class AnythingLLMChatResponse(val textResponse: String) +@Serializable data class AnythingLLMChatResponse(val textResponse: String?) + +// [⭐️ 수정] AnythingLLM Vision API의 정확한 규격에 맞춘 데이터 클래스 +@Serializable +data class AnythingLLMChatRequestWithVision( + val message: String, + val mode: String = "chat", + val sessionId: String? = null, + val attachments: List = emptyList(), + val reset: Boolean = false +) { + @Serializable + data class Attachment( + val name: String, + val mime: String, + val contentString: 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 selectedImageUrl: String?, + val selectedImageUrls: List, val allImageUrls: List, val content: String ) @@ -65,14 +87,17 @@ data class ScrapedData( // --- 전역 변수 및 헬퍼 --- private val httpClient = HttpClient(CIO) { install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) } - install(HttpTimeout) { requestTimeoutMillis = 300000 } + install(HttpTimeout) { requestTimeoutMillis = 1800000 } } -private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true } +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" +// [⭐️ 추가] 영수증 처리용 워크스페이스 슬러그를 위한 새로운 Preference 키 +private const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" private const val PREF_MODEL_NAME = "model_name" fun logMessage(logs: MutableList, message: String) { @@ -92,7 +117,7 @@ private fun quitChromeDriver() { 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" @@ -145,7 +170,7 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList, isBrowser currentDriver.get(url) Thread.sleep(2000) val doc = Jsoup.parse(currentDriver.pageSource) - val articleContent = doc.select("article, .article-body, #article_body, .news-article-body-view").text() + 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") } @@ -158,7 +183,7 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList, isBrowser logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)") ScrapedData( sourceUrl = url, - selectedImageUrl = allImages.firstOrNull(), + selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent ) @@ -202,28 +227,54 @@ suspend fun uploadFilesToLLM(files: List, logs: MutableList, apiKe return uploadedDocIds } -suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List, userDirection: String, logs: MutableList, apiKey: String, workspaceSlug: String): String { +suspend fun generateBlogPostWithLocalLLM( + scrapedDataList: List, + userOwnContent: String, + allSelectedImages: List, + userDirection: String, + logs: MutableList, + apiKey: String, + workspaceSlug: String +): String { logMessage(logs, "LLM 블로그 글 생성 요청...") - val chosenImage = scrapedDataList.firstNotNullOfOrNull { it.selectedImageUrl } - val referencesText = scrapedDataList.mapIndexed { index, data -> - "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" - }.joinToString("\n") + + val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) { + val referencesText = scrapedDataList.mapIndexed { index, data -> + "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" + }.joinToString("\n") + """ + --- 참고자료 --- + $referencesText + """.trimIndent() + } else { + """ + --- 주요 내용 --- + 아래 내용을 바탕으로 글을 작성해주세요. + $userOwnContent + """.trimIndent() + } + + val imagePromptPart = if (allSelectedImages.isNotEmpty()) { + val imageMarkdown = allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n") + "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n$imageMarkdown" + } else "" val finalPrompt = """ 당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. - 아래 '참고자료'와 '요청사항'을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. + 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. - --- 참고자료 --- - $referencesText + $contentSourcePromptPart --- 요청사항 --- 1. 사용자 요청: "$userDirection" - 2. 참고자료의 내용을 종합적으로 활용하여 하나의 완성된 글을 작성해주세요. + 2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요. 3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요. - ${if (chosenImage != null) "4. 글의 적절한 위치에 마크다운 형식 `![대표 이미지]($chosenImage)`으로 이미지를 삽입해주세요." else ""} + $imagePromptPart 5. 글 마지막에 출처에 대한 언급은 절대 하지 마세요. """.trimIndent() + // 텍스트 기반 생성은 RAG가 가능한 chat API를, 이미지(영수증) 분석은 Vision이 가능한 chat API를 사용합니다. + // 여기서는 텍스트 기반 글 생성이므로 기존 `AnythingLLMChatRequest`를 사용합니다. val requestBody = AnythingLLMChatRequest(message = finalPrompt) try { val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") { @@ -235,7 +286,7 @@ suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List, use logMessage(logs, "LLM 응답 수신...") val chatResponse = jsonParser.decodeFromString(responseBodyText) logMessage(logs, "✅ LLM 블로그 글 생성 완료.") - return chatResponse.textResponse + return chatResponse.textResponse ?: "" } catch (e: Exception) { logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}") return "블로그 글 생성 실패: ${e.message}" @@ -256,6 +307,116 @@ suspend fun cleanupLLMWorkspace(docIds: List, logs: MutableList, } catch (e: Exception) { logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}") } } + +@Serializable +data class Message(val role: String, val content: String) + +@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, + // UI와 직접 연결된 StateFlow를 받아 실시간 업데이트 + resultState: MutableStateFlow +) { + if (apiKey.isBlank()) { + logs.add("AnythingLLM API Key is missing.") + resultState.value = "API Key missing" + return + } + if (receiptWorkspaceSlug.isBlank()) { + logs.add("Receipt workspace slug is missing.") + resultState.value = "Workspace Slug missing" + return + } + if (files.isEmpty()) { + logs.add("No receipt files provided.") + resultState.value = "No receipt files provided." + return + } + logs.add("Starting receipt analysis with stream-chat mode...") + resultState.value = "영수증 분석 중..." // 분석 시작 알림 + + try { + val messages = "첨부된 영수증 이미지들을 분석해서, 각 영수증별로 지출 내역을 정리해줘. 날짜, 항목, 금액이 잘 드러나게 마크다운 형식으로 깔끔하게 요약해줘." + 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 requestBody = StreamChatRequest( + message = messages, + attachments = attachments, + mode = "chat", + sessionId = "receipt-analysis-${System.currentTimeMillis()}" + ) + + // preparePost를 사용하여 스트리밍 응답을 더 세밀하게 제어 + httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") { + accept(ContentType.Application.Json) // 또는 "text/event-stream" + 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) { + // 한 줄씩 읽어옵니다. SSE는 보통 줄 단위로 데이터가 옵니다. + val line = channel.readUTF8Line() ?: continue + println(line) + // SSE 형식 (data: { ... })에서 실제 JSON 부분만 추출 + if (line.startsWith("data:")) { + val jsonChunk = line.removePrefix("data:").trim() + + // 스트리밍 데이터 조각을 파싱 (라이브러리 응답 형식에 맞춰야 함) + // 예: {"textResponse": "결과"} 와 같은 조각이 온다고 가정 + try { + val chunkResponse = jsonParser.decodeFromString(jsonChunk) + fullResponseText += chunkResponse.textResponse + + // 💥 UI 상태를 실시간으로 업데이트! + resultState.value = fullResponseText + } catch (e: Exception) { + // 파싱 오류는 무시하거나 로그를 남길 수 있습니다. + e.printStackTrace() + } + } else { + + } + } + logs.add("Receipt analysis stream completed.") + } else { + val errorBody = response.bodyAsText() + logs.add("Error during receipt analysis: ${response.status} - $errorBody") + resultState.value = "API 오류: ${response.status}" + } + } + } catch (e: Exception) { + logs.add("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ㄱ-ㅎㅏ-ㅣ가-힣]"), "") @@ -282,6 +443,24 @@ fun loadScrapedJsonFiles(logs: MutableList, folderPath: String): 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) { @@ -298,11 +477,13 @@ fun App(imageLoader: ImageLoader) { var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") } var isBrowserVisible by remember { mutableStateOf(true) } - var keepBrowserSession by remember { mutableStateOf(false) } + 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")) } - var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "gpt-4o")) } + var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my-workspace")) } + // [⭐️ 추가] 영수증 처리용 슬러그 상태 변수 추가, 기본값 "receipts" + 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()) } @@ -310,14 +491,53 @@ fun App(imageLoader: ImageLoader) { var currentlyOpenFile by remember { mutableStateOf(null) } var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") } var imagesForSelection by remember { mutableStateOf>(emptyList()) } - var currentSelectedImage by remember { mutableStateOf(null) } + 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 isReceiptDialogVisible by remember { mutableStateOf(false) } + + var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") } + val receiptAnalysisResultFlow = remember { MutableStateFlow(receiptAnalysisResult) } + + LaunchedEffect(Unit) { + receiptAnalysisResultFlow.collect { newResult -> + receiptAnalysisResult = newResult + } + } LaunchedEffect(scrapedFolderPath) { 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) } + // [⭐️ 추가] 영수증 슬러그 값이 변경될 때마다 Preferences에 저장 + 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)) { @@ -330,13 +550,17 @@ fun App(imageLoader: ImageLoader) { Spacer(Modifier.height(8.dp)) OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true) Spacer(Modifier.height(4.dp)) + // [⭐️ 수정] API 키 필드를 한 줄 전체 사용하도록 변경 + OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation()) + Spacer(Modifier.height(4.dp)) + // [⭐️ 수정] Workspace Slug 입력 필드를 두 개로 나누어 배치 Row(Modifier.fillMaxWidth()) { - OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.weight(1f), singleLine = true, visualTransformation = PasswordVisualTransformation()) + OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("블로그용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true) Spacer(Modifier.width(4.dp)) - OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true) + 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) + OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("사용 중인 LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true) } Divider() TabRow(selectedTabIndex = tabIndex) { @@ -345,20 +569,28 @@ fun App(imageLoader: ImageLoader) { when (tabIndex) { 0 -> WorkflowTab( isLoading = isLoading, keywords = keywords, searchResults = searchResults, scrapedFiles = scrapedFiles, selectedFiles = selectedFiles, - viewedFileContent = viewedFileContent, imagesForSelection = imagesForSelection, currentSelectedImage = currentSelectedImage, + 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; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, 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 savedFile = saveDataToJsonFile(selectedKeyword, data, logMessages, scrapedFolderPath) + 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 - currentSelectedImage = data.selectedImageUrl + currentSelectedImages = data.selectedImageUrls.toSet() } isLoading = false } @@ -372,55 +604,105 @@ fun App(imageLoader: ImageLoader) { currentlyOpenFile = file viewedFileContent = data.content imagesForSelection = data.allImageUrls - currentSelectedImage = data.selectedImageUrl + currentSelectedImages = data.selectedImageUrls.toSet() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") } } }, - onImageSelect = { imageUrl -> currentSelectedImage = imageUrl }, + 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(selectedImageUrl = currentSelectedImage) + 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() || modelName.isBlank()) { - logMessage(logMessages, "⚠️ API 키, 슬러그, 모델 이름을 모두 입력해주세요.") + if (apiKey.isBlank() || workspaceSlug.isBlank()) { + logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요.") return@WorkflowTab } - if (selectedFiles.isNotEmpty()) { - coroutineScope.launch(Dispatchers.IO) { - isLoading = true - var uploadedDocIds: List = emptyList() + + coroutineScope.launch(Dispatchers.IO) { + isLoading = true + try { val selectedDataList = selectedFiles.map { jsonParser.decodeFromString(it.readText()) } - try { - uploadedDocIds = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug) - if (uploadedDocIds.isNotEmpty()) { - val resultText = generateBlogPostWithLocalLLM(selectedDataList, userPrompt, logMessages, apiKey, workspaceSlug) - val footer = buildString { - appendLine("\n\n---") - appendLine("- 원문 출처:") - selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") } - selectedDataList.firstNotNullOfOrNull { it.selectedImageUrl }?.let { appendLine("- 이미지 출처: $it") } - appendLine("- 이 글은 ${modelName} 모델을 활용하여 작성되었습니다.") - } - blogPostResult = resultText + footer - tabIndex = 2 - } else { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.") } - } finally { - cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey) - isLoading = false + val uploadedImageUrls = uploadedImageFiles.map { it.toURI().toString() } + + 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 { + uploadedImageUrls } + + if (!isScrapBased && (userOwnContent.isBlank() || allImages.isEmpty())) { + logMessage(logMessages, "⚠️ 직접 포스팅을 하려면 '직접 작성' 내용과 '업로드한 이미지'가 모두 필요합니다.") + return@launch + } + + var uploadedDocIds: List = emptyList() + if (isScrapBased) { + uploadedDocIds = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug) + if (uploadedDocIds.isEmpty()) { + logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.") + return@launch + } + } + + 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 } - } else { logMessage(logMessages, "⚠️ 블로그 글을 생성할 파일을 선택해주세요.") } + } + }, + receiptFiles = receiptFiles, + receiptAnalysisResult = receiptAnalysisResult, + onUploadReceipt = { isReceiptDialogVisible = true }, + onRemoveReceipt = { file -> receiptFiles = receiptFiles - file }, + onAnalyzeReceipts = { + coroutineScope.launch(Dispatchers.IO) { + isLoading = true + // [⭐️ 수정] 영수증 분석 함수 호출 시, 'receiptWorkspaceSlug' 값을 전달 + analyzeReceiptsWithStreamChat(receiptFiles, apiKey, receiptWorkspaceSlug, logMessages, receiptAnalysisResultFlow) + isLoading = false + } } ) 1 -> LogTab(logMessages) @@ -433,17 +715,46 @@ fun App(imageLoader: ImageLoader) { @Composable fun WorkflowTab( isLoading: Boolean, keywords: List, searchResults: List, scrapedFiles: List, selectedFiles: Set, - viewedFileContent: String, imagesForSelection: List, currentSelectedImage: String?, userPrompt: String,imageLoader: ImageLoader, + 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 + 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 ) { 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("트렌드 가져오기") } - LazyColumn(modifier = Modifier.fillMaxSize()) { items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) } } + 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()) { @@ -455,50 +766,111 @@ fun WorkflowTab( } } } - Column(modifier = Modifier.weight(1.5f).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("새로고침") } - } - LazyColumn(modifier = Modifier.fillMaxSize()) { - 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) + + 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)) + 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("파일 내용", style = MaterialTheme.typography.h6) - Text(text = viewedFileContent, modifier = Modifier.weight(0.5f).fillMaxWidth().verticalScroll(rememberScrollState()).border(1.dp, Color.LightGray).padding(4.dp), style = MaterialTheme.typography.body2) - Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("대표 이미지 선택", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f)) - Button(onClick = onSaveChanges, enabled = !isLoading && imagesForSelection.isNotEmpty()) { Text("선택 이미지 저장") } - } - LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray), userScrollEnabled = true) { - items(imagesForSelection) { imageUrl -> - val isSelected = imageUrl == currentSelectedImage - Box(modifier = Modifier.padding(4.dp)) { - Image( - painter = rememberAsyncImagePainter( - model = imageUrl, - imageLoader = imageLoader // 이 부분을 추가하세요. - ), - contentDescription = "Scraped Image", - modifier = Modifier.size(100.dp).clickable { onImageSelect(imageUrl) }.border(if (isSelected) 4.dp else 0.dp, MaterialTheme.colors.primary), - contentScale = ContentScale.Crop - ) - } - } - } - Spacer(Modifier.height(8.dp)) 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(), modifier = Modifier.fillMaxWidth()) { - Text("블로그 글 생성하기 (${selectedFiles.size}개 파일)") + Button( + onClick = onGeneratePost, + enabled = !isLoading && (selectedFiles.isNotEmpty() || (userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty())), + modifier = Modifier.fillMaxWidth() + ) { + val buttonText = if (selectedFiles.isNotEmpty()) { + "스크랩 기반 글 생성 (${selectedFiles.size}개 파일)" + } else { + "직접 작성 기반 글 생성" + } + Text(buttonText) } } } @@ -521,21 +893,14 @@ fun ResultTab(result: String) { } fun main() = application { - // ⭐️ [Coil 3 변경] 데스크톱용 ImageLoader 생성 val imageLoader = ImageLoader.Builder(coil3.PlatformContext.INSTANCE) .components { add(OkHttpNetworkFetcherFactory()) } .build() Window( onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() }, - title = "자동 블로그 포스팅 도우미 v4.3" + title = "자동 블로그 포스팅 도우미 v7.1 (Receipt-Workspace-Separated)" ) { - // ❌ [삭제] CompositionLocalProvider는 더 이상 사용하지 않습니다. - // CompositionLocalProvider(LocalImageLoader provides imageLoader) { - // App() - // } - - // ✅ [수정] App 함수에 imageLoader를 파라미터로 직접 전달합니다. App(imageLoader) } } \ No newline at end of file