This commit is contained in:
lunaticbum 2025-10-02 16:09:23 +09:00
parent e826522192
commit c016328f1d

View File

@ -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<Attachment> = 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<Document>) { @Serializable data class Document(val id: String, val location: String) }
@Serializable data class DeleteDocumentsRequest(val deletes: List<String>)
data class SearchResult(val title: String, val url: String)
@Serializable
data class ScrapedData(
val sourceUrl: String,
val selectedImageUrl: String?,
val selectedImageUrls: List<String>,
val allImageUrls: List<String>,
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<String>, message: String) {
@ -92,7 +117,7 @@ private fun quitChromeDriver() {
driver = null
}
// --- 핵심 기능 함수들 (이하 로직은 동일) ---
// --- 핵심 기능 함수들 ---
suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<String> {
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<String>, 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<String>, 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<File>, logs: MutableList<String>, apiKe
return uploadedDocIds
}
suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, userDirection: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
suspend fun generateBlogPostWithLocalLLM(
scrapedDataList: List<ScrapedData>,
userOwnContent: String,
allSelectedImages: List<String>,
userDirection: String,
logs: MutableList<String>,
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<ScrapedData>, use
logMessage(logs, "LLM 응답 수신...")
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(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<String>, logs: MutableList<String>,
} 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<Attachment>,
val mode: String,
val sessionId: String
)
suspend fun analyzeReceiptsWithStreamChat(
files: List<File>,
apiKey: String,
receiptWorkspaceSlug: String,
logs: MutableList<String>,
// UI와 직접 연결된 StateFlow를 받아 실시간 업데이트
resultState: MutableStateFlow<String>
) {
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<AnythingLLMChatResponse>(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<String>, 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<String>, folderPath: String): List<Fi
}
}
@Composable
fun FileDialog(
parent: Frame? = null,
onCloseRequest: (result: List<File>) -> 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<List<File>>(emptyList()) }
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
@ -310,14 +491,53 @@ fun App(imageLoader: ImageLoader) {
var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
var imagesForSelection by remember { mutableStateOf<List<String>>(emptyList()) }
var currentSelectedImage by remember { mutableStateOf<String?>(null) }
var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) }
var manualKeyword by remember { mutableStateOf("") }
var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다. 경포 해변의 일출이 정말 아름다웠고, 근처 카페에서 마신 커피도 훌륭했습니다.") }
var isImageUploadDialogVisible by remember { mutableStateOf(false) }
var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptFiles by remember { mutableStateOf<List<File>>(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<ScrapedData>(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<String> = emptyList()
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
try {
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(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<String> = 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<String>, searchResults: List<SearchResult>, scrapedFiles: List<File>, selectedFiles: Set<File>,
viewedFileContent: String, imagesForSelection: List<String>, currentSelectedImage: String?, userPrompt: String,imageLoader: ImageLoader,
viewedFileContent: String, imagesForSelection: List<String>, currentSelectedImages: Set<String>,
userPrompt: String, imageLoader: ImageLoader,
manualKeyword: String, onManualKeywordChange: (String) -> Unit,
userOwnContent: String, onUserOwnContentChange: (String) -> Unit,
uploadedImageFiles: List<File>,
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<File>,
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)
}
}