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

View File

@ -31,14 +31,16 @@ import io.ktor.client.request.forms.formData
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.*
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonClassDiscriminator
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.openqa.selenium.By import org.openqa.selenium.By
import org.openqa.selenium.WebDriverException import org.openqa.selenium.WebDriverException
@ -50,53 +52,47 @@ import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.prefs.Preferences import java.util.prefs.Preferences
import kotlin.coroutines.cancellation.CancellationException
// --- 데이터 클래스 정의 --- // --- 데이터 클래스 정의 (Sealed Interface 사용) ---
@Serializable data class AnythingLLMChatRequest(val message: String)
@Serializable data class AnythingLLMChatResponse(val textResponse: String?)
// [⭐️ 수정] AnythingLLM Vision API의 정확한 규격에 맞춘 데이터 클래스
@Serializable @Serializable
data class AnythingLLMChatRequestWithVision( @JsonClassDiscriminator("type")
val message: String, sealed interface SealedLLMStreamResponse {
val mode: String = "chat",
val sessionId: String? = null,
val attachments: List<Attachment> = emptyList(),
val reset: Boolean = false
) {
@Serializable @Serializable
data class Attachment( @SerialName("textResponseChunk")
val name: String, data class TextResponseChunk(val uuid: String, val textResponse: String?, val sources: List<Source> = emptyList(), val close: Boolean, val error: Boolean) : SealedLLMStreamResponse
val mime: String, @Serializable
val contentString: String @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<Document>) { @Serializable data class Document(val id: String, val location: 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>) @Serializable data class DeleteDocumentsRequest(val deletes: List<String>)
data class SearchResult(val title: String, val url: String) data class SearchResult(val title: String, val url: String)
@Serializable @Serializable
data class ScrapedData( data class ScrapedData(val sourceUrl: String, val selectedImageUrls: List<String>, val allImageUrls: List<String>, val content: String)
val sourceUrl: String,
val selectedImageUrls: List<String>,
val allImageUrls: List<String>,
val content: String
)
// --- 전역 변수 및 헬퍼 --- // --- 전역 변수 및 헬퍼 ---
private val httpClient = HttpClient(CIO) { private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) } install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) }
install(HttpTimeout) { requestTimeoutMillis = 1800000 } install(HttpTimeout) { requestTimeoutMillis = 900000 }
} }
private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true; private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = true }
coerceInputValues = true }
private var driver: ChromeDriver? = null private var driver: ChromeDriver? = null
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java) private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
private const val PREF_FOLDER_PATH = "folder_path" private const val PREF_FOLDER_PATH = "folder_path"
private const val PREF_API_KEY = "api_key" private const val PREF_API_KEY = "api_key"
private const val PREF_WORKSPACE_SLUG = "workspace_slug" private const val PREF_WORKSPACE_SLUG = "workspace_slug"
// [⭐️ 추가] 영수증 처리용 워크스페이스 슬러그를 위한 새로운 Preference 키
private const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug" private const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
private const val PREF_MODEL_NAME = "model_name" private const val PREF_MODEL_NAME = "model_name"
@ -111,13 +107,9 @@ private fun getChromeDriver(options: ChromeOptions): ChromeDriver {
if (driver == null) { driver = ChromeDriver(options) } if (driver == null) { driver = ChromeDriver(options) }
return driver!! return driver!!
} }
private fun quitChromeDriver() { driver?.quit(); driver = null }
private fun quitChromeDriver() { // --- 핵심 기능 함수들 (스크랩, 업로드 등) ---
driver?.quit()
driver = null
}
// --- 핵심 기능 함수들 ---
suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<String> { suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<String> {
logMessage(logs, "Google Trends 페이지 스크랩 시작...") logMessage(logs, "Google Trends 페이지 스크랩 시작...")
val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR" val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR"
@ -126,15 +118,10 @@ suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boole
return try { return try {
currentDriver.get(trendsUrl) currentDriver.get(trendsUrl)
Thread.sleep(5000) Thread.sleep(5000)
currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")) currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")).mapNotNull { it.text.takeIf(String::isNotBlank) }.also { logMessage(logs, "✅ Google Trends 키워드 ${it.size}개 스크랩 완료.") }
.mapNotNull { it.text.takeIf(String::isNotBlank) }.also { logMessage(logs, "✅ Google Trends 키워드 ${it.size}개 스크랩 완료.") }
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}") logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}"); quitChromeDriver(); emptyList()
quitChromeDriver() } finally { if (!keepSession) quitChromeDriver() }
emptyList()
} finally {
if (!keepSession) quitChromeDriver()
}
} }
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> { suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> {
@ -154,11 +141,8 @@ suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowser
} }
logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.") logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.")
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ Google 검색 중 오류: ${e.message}") logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); quitChromeDriver()
quitChromeDriver() } finally { if (!keepSession) quitChromeDriver() }
} finally {
if (!keepSession) quitChromeDriver()
}
return results return results
} }
@ -171,36 +155,18 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowser
Thread.sleep(2000) Thread.sleep(2000)
val doc = Jsoup.parse(currentDriver.pageSource) val doc = Jsoup.parse(currentDriver.pageSource)
val articleContent = doc.select("#app, .app, 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") 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()
.map { it.absUrl("src") } if (articleContent.isBlank()) { logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다."); null }
.filter { it.isNotBlank() && it.startsWith("http") } else { logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)"); ScrapedData(sourceUrl = url, selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent) }
.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) { } catch (e: Exception) {
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}") logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}"); quitChromeDriver(); null
quitChromeDriver() } finally { if (!keepSession) quitChromeDriver() }
null
} finally {
if (!keepSession) quitChromeDriver()
}
} }
suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> { suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> {
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...") logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
val uploadedDocIds = mutableListOf<String>() val uploadedDocIds = mutableListOf<String>()
for (file in files) { files.forEach { file ->
logMessage(logs, " - '${file.name}' 업로드 중...") logMessage(logs, " - '${file.name}' 업로드 중...")
try { try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/upload") { val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/upload") {
@ -208,18 +174,12 @@ suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKe
setBody(MultiPartFormDataContent(formData { setBody(MultiPartFormDataContent(formData {
append("addToWorkspaces", workspaceSlug) append("addToWorkspaces", workspaceSlug)
val scrapedData = jsonParser.decodeFromString<ScrapedData>(file.readText()) val scrapedData = jsonParser.decodeFromString<ScrapedData>(file.readText())
append("file", scrapedData.content.toByteArray(), Headers.build { append("file", scrapedData.content.toByteArray(), Headers.build { append(HttpHeaders.ContentType, ContentType.Text.Plain); append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") })
append(HttpHeaders.ContentType, ContentType.Text.Plain)
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
})
})) }))
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val uploadResponse = response.body<UploadResponse>() val uploadResponse = response.body<UploadResponse>()
uploadResponse.documents.firstOrNull()?.id?.let { uploadResponse.documents.firstOrNull()?.id?.let { uploadedDocIds.add(it); logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).") } ?: logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
uploadedDocIds.add(it)
logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).")
} ?: logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
} else { logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}") } } else { logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}") }
} catch (e: Exception) { logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}") } } catch (e: Exception) { logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}") }
} }
@ -227,69 +187,20 @@ suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKe
return uploadedDocIds return uploadedDocIds
} }
suspend fun generateBlogPostWithLocalLLM( suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, userOwnContent: String, allSelectedImages: List<String>, userDirection: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
scrapedDataList: List<ScrapedData>,
userOwnContent: String,
allSelectedImages: List<String>,
userDirection: String,
logs: MutableList<String>,
apiKey: String,
workspaceSlug: String
): String {
logMessage(logs, "LLM 블로그 글 생성 요청...") 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 contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) { val imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n")}" else ""
val referencesText = scrapedDataList.mapIndexed { index, data -> val finalPrompt = """당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요. $contentSourcePromptPart --- 요청사항 ---\n1. 사용자 요청: "$userDirection"\n2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.\n3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요. $imagePromptPart\n5. 글 마지막에 출처에 대한 언급은 절대 하지 마세요."""
"[참고자료 ${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에 최적화된 블로그 글을 작성하는 전문 작가입니다.
아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요.
$contentSourcePromptPart
--- 요청사항 ---
1. 사용자 요청: "$userDirection"
2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3 추천해주세요.
$imagePromptPart
5. 마지막에 출처에 대한 언급은 절대 하지 마세요.
""".trimIndent()
// 텍스트 기반 생성은 RAG가 가능한 chat API를, 이미지(영수증) 분석은 Vision이 가능한 chat API를 사용합니다.
// 여기서는 텍스트 기반 글 생성이므로 기존 `AnythingLLMChatRequest`를 사용합니다.
val requestBody = AnythingLLMChatRequest(message = finalPrompt)
try { try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") { val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
header("Authorization", "Bearer $apiKey") header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
contentType(ContentType.Application.Json)
setBody(requestBody)
} }
val responseBodyText = response.bodyAsText() val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
logMessage(logs, "LLM 응답 수신...")
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(responseBodyText)
logMessage(logs, "✅ LLM 블로그 글 생성 완료.") logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
return chatResponse.textResponse ?: "" return chatResponse.textResponse
} catch (e: Exception) { } catch (e: Exception) {
logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}") logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}"); return "블로그 글 생성 실패: ${e.message}"
return "블로그 글 생성 실패: ${e.message}"
} }
} }
@ -298,176 +209,82 @@ suspend fun cleanupLLMWorkspace(docIds: List<String>, logs: MutableList<String>,
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...") logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...")
try { try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") { val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") {
header("Authorization", "Bearer $apiKey") header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(DeleteDocumentsRequest(deletes = docIds))
contentType(ContentType.Application.Json)
setBody(DeleteDocumentsRequest(deletes = docIds))
} }
if (response.status.isSuccess()) { logMessage(logs, "✅ LLM 워크스페이스 정리 완료.") } if (response.status.isSuccess()) { logMessage(logs, "✅ LLM 워크스페이스 정리 완료.") }
else { logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}") } else { logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}") }
} catch (e: Exception) { logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}") } } 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<Attachment>, val mode: String, val sessionId: String)
@Serializable suspend fun analyzeReceiptsWithStreamChat(files: List<File>, apiKey: String, receiptWorkspaceSlug: String, logs: MutableList<String>, resultState: MutableStateFlow<String>, receiptContext: String) {
data class Message(val role: String, val content: 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
@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()) { logMessage(logs, "Starting receipt analysis with stream-chat mode...")
logs.add("Receipt workspace slug is missing.") resultState.value = "영수증 분석 중..."
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 { 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 attachments = files.map { file -> val basePrompt = "첨부된 영수증 이미지들을 분석해줘."
val base64Image = Base64.getEncoder().encodeToString(file.readBytes()) val finalPrompt = if (receiptContext.isNotBlank()) """$basePrompt --- 추가 정보 --- $receiptContext --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘.""" else basePrompt
val mimeType = when (file.extension.lowercase()) { logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}")
"png" -> "image/png" val requestBody = StreamChatRequest(message = finalPrompt, attachments = attachments, mode = "chat", sessionId = "receipt-analysis-${System.currentTimeMillis()}")
"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") { httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") {
accept(ContentType.Application.Json) // 또는 "text/event-stream" accept(ContentType.Application.Json); header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(requestBody)
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(requestBody)
}.execute { response -> }.execute { response ->
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val channel: ByteReadChannel = response.bodyAsChannel() val channel: ByteReadChannel = response.bodyAsChannel()
var fullResponseText = "" var fullResponseText = ""
// 채널에서 데이터가 끝날 때까지 계속 읽어들입니다.
while (!channel.isClosedForRead) { while (!channel.isClosedForRead) {
// 한 줄씩 읽어옵니다. SSE는 보통 줄 단위로 데이터가 옵니다.
val line = channel.readUTF8Line() ?: continue val line = channel.readUTF8Line() ?: continue
println(line)
// SSE 형식 (data: { ... })에서 실제 JSON 부분만 추출
if (line.startsWith("data:")) { if (line.startsWith("data:")) {
val jsonChunk = line.removePrefix("data:").trim() val jsonChunk = line.removePrefix("data:").trim()
if (jsonChunk.isEmpty() || jsonChunk == "[DONE]") continue
// 스트리밍 데이터 조각을 파싱 (라이브러리 응답 형식에 맞춰야 함)
// 예: {"textResponse": "결과"} 와 같은 조각이 온다고 가정
try { try {
val chunkResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(jsonChunk) when (val streamObject = jsonParser.decodeFromString<SealedLLMStreamResponse>(jsonChunk)) {
fullResponseText += chunkResponse.textResponse 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 }
// 💥 UI 상태를 실시간으로 업데이트! is SealedLLMStreamResponse.AbortResponse -> { logMessage(logs, "⚠️ Stream aborted by server. Reason: ${streamObject.textResponse}"); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break }
resultState.value = fullResponseText }
} catch (e: Exception) { } catch (e: Exception) { logMessage(logs, "❌ Chunk parsing error: ${e.message} | Chunk: $jsonChunk") }
// 파싱 오류는 무시하거나 로그를 남길 수 있습니다.
e.printStackTrace()
}
} else {
} }
} }
logs.add("Receipt analysis stream completed.") logMessage(logs, "Receipt analysis stream processing finished.")
} else { } else { val errorBody = response.bodyAsText(); logMessage(logs, "❌ Error during receipt analysis: ${response.status} - $errorBody"); resultState.value = "API 오류: ${response.status}" }
val errorBody = response.bodyAsText()
logs.add("Error during receipt analysis: ${response.status} - $errorBody")
resultState.value = "API 오류: ${response.status}"
}
} }
} catch (e: Exception) { } catch (e: CancellationException) { throw e }
logs.add("Exception in receipt analysis: ${e.message}") catch (e: Exception) { logMessage(logs, "❌ Exception in receipt analysis: ${e.message}"); resultState.value = e.message ?: "알 수 없는 오류" }
resultState.value = e.message ?: "알 수 없는 오류"
}
} }
fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList<String>, folderPath: String): File { fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList<String>, folderPath: String): File {
val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() } val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() }
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "") val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json" val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName) val file = File(directory, fileName)
try { file.writeText(jsonParser.encodeToString(data))
file.writeText(jsonParser.encodeToString(data)) logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.")
logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.")
} catch (e: Exception) {
logMessage(logs, "❌ 파일 저장 중 오류: ${e.message}")
}
return file return file
} }
fun loadScrapedJsonFiles(logs: MutableList<String>, folderPath: String): List<File> { fun loadScrapedJsonFiles(logs: MutableList<String>, folderPath: String): List<File> {
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...") logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
return try { return File(folderPath).listFiles { _, name -> name.endsWith(".json") }?.sortedByDescending { it.lastModified() }.orEmpty().also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") }
File(folderPath).listFiles { _, name -> name.endsWith(".json") }
?.sortedByDescending { it.lastModified() }
.orEmpty().also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") }
} catch (e: Exception) {
logMessage(logs, "❌ 파일 로딩 중 오류: ${e.message}")
emptyList()
}
} }
@Composable @Composable
fun FileDialog( fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List<File>) -> Unit) {
parent: Frame? = null, val fileDialog = remember { FileDialog(parent, "파일 선택", FileDialog.LOAD).apply { isMultipleMode = true } }
onCloseRequest: (result: List<File>) -> Unit LaunchedEffect(Unit) { fileDialog.isVisible = true; onCloseRequest(fileDialog.files.toList()) }
) {
val fileDialog = remember {
FileDialog(parent, "파일 선택", FileDialog.LOAD).apply {
isMultipleMode = true
}
}
LaunchedEffect(Unit) {
fileDialog.isVisible = true
onCloseRequest(fileDialog.files.toList())
}
} }
// --- UI 컴포넌트 --- // --- UI 컴포넌트 ---
@Composable @Composable
fun App(imageLoader: ImageLoader) { fun App(imageLoader: ImageLoader) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var tabIndex by remember { mutableStateOf(0) } var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("워크플로우", "통신 로그", "블로그 결과") val tabs = listOf("워크플로우", "통신 로그", "블로그 결과")
var keywords by remember { mutableStateOf<List<String>>(emptyList()) } var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) } var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") } var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
@ -475,97 +292,48 @@ fun App(imageLoader: ImageLoader) {
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var selectedKeyword by remember { mutableStateOf("") } var selectedKeyword by remember { mutableStateOf("") }
var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") } var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") }
var isBrowserVisible by remember { mutableStateOf(true) } var isBrowserVisible by remember { mutableStateOf(true) }
var keepBrowserSession by remember { mutableStateOf(true) } var keepBrowserSession by remember { mutableStateOf(true) }
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) } var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) }
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) } var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my-workspace")) } 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 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 modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "Llama-3.1-8B-Vision")) }
var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) } var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) } var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
var currentlyOpenFile by remember { mutableStateOf<File?>(null) } var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") } var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
var imagesForSelection by remember { mutableStateOf<List<String>>(emptyList()) } var imagesForSelection by remember { mutableStateOf<List<String>>(emptyList()) }
var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) } var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) }
var manualKeyword by remember { mutableStateOf("") } var manualKeyword by remember { mutableStateOf("") }
var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다. 경포 해변의 일출이 정말 아름다웠고, 근처 카페에서 마신 커피도 훌륭했습니다.") } var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다.") }
var isImageUploadDialogVisible by remember { mutableStateOf(false) } var isImageUploadDialogVisible by remember { mutableStateOf(false) }
var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) } var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptFiles 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("영수증을 업로드하고 분석을 시작하세요.") } var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") }
val receiptAnalysisResultFlow = remember { MutableStateFlow(receiptAnalysisResult) } var isReceiptDialogVisible by remember { mutableStateOf(false) }
var analysisJob by remember { mutableStateOf<Job?>(null) }
var receiptContextPrompt by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath); prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) }
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(apiKey) { prefs.put(PREF_API_KEY, apiKey) }
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) } LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
// [⭐️ 추가] 영수증 슬러그 값이 변경될 때마다 Preferences에 저장
LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) } LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) }
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) } LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
if (isImageUploadDialogVisible) { if (isImageUploadDialogVisible) { FileDialog { selected -> isImageUploadDialogVisible = false; if (selected.isNotEmpty()) { uploadedImageFiles = uploadedImageFiles + selected; logMessage(logMessages, "✅ 개인 이미지 파일 ${selected.size}개 추가됨.") } } }
FileDialog { selected -> if (isReceiptDialogVisible) { FileDialog { selected -> isReceiptDialogVisible = false; if (selected.isNotEmpty()) { receiptFiles = receiptFiles + selected; logMessage(logMessages, "🧾 영수증 이미지 ${selected.size}개 추가됨.") } } }
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 { MaterialTheme {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) { Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { 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)) }
Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it }) Spacer(Modifier.height(8.dp)); OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true); Spacer(Modifier.height(4.dp)); OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation()); Spacer(Modifier.height(4.dp))
Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f)) 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) }
Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it }) Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("사용 중인 LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
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))
// [⭐️ 수정] 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 = 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() Divider()
TabRow(selectedTabIndex = tabIndex) { TabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } }
tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) }
}
when (tabIndex) { when (tabIndex) {
0 -> WorkflowTab( 0 -> WorkflowTab(
isLoading = isLoading, keywords = keywords, searchResults = searchResults, scrapedFiles = scrapedFiles, selectedFiles = selectedFiles, isLoading = isLoading, keywords = keywords, searchResults = searchResults, scrapedFiles = scrapedFiles, selectedFiles = selectedFiles,
@ -575,135 +343,37 @@ fun App(imageLoader: ImageLoader) {
userOwnContent = userOwnContent, onUserOwnContentChange = { userOwnContent = it }, userOwnContent = userOwnContent, onUserOwnContentChange = { userOwnContent = it },
uploadedImageFiles = uploadedImageFiles, uploadedImageFiles = uploadedImageFiles,
onFetchTrends = { coroutineScope.launch(Dispatchers.IO) { isLoading = true; keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } }, onFetchTrends = { coroutineScope.launch(Dispatchers.IO) { isLoading = true; keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
onKeywordSelect = { keyword -> onKeywordSelect = { keyword -> selectedKeyword = keyword; manualKeyword = keyword; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
selectedKeyword = keyword 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 } },
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) } }, onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) } },
onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file }, onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file },
onFileView = { file -> onFileView = { file -> coroutineScope.launch(Dispatchers.IO) { try { val data = jsonParser.decodeFromString<ScrapedData>(file.readText()); currentlyOpenFile = file; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") } } },
coroutineScope.launch(Dispatchers.IO) { onImageSelect = { imageUrl -> currentSelectedImages = if (imageUrl in currentSelectedImages) currentSelectedImages - imageUrl else currentSelectedImages + imageUrl },
try { onSaveChanges = { coroutineScope.launch(Dispatchers.IO) { currentlyOpenFile?.let { file -> try { val originalData = jsonParser.decodeFromString<ScrapedData>(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}") } } } },
val data = jsonParser.decodeFromString<ScrapedData>(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<ScrapedData>(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 }, onUploadImage = { isImageUploadDialogVisible = true },
onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove }, onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove },
onGeneratePost = { 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<ScrapedData>(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 } } },
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<ScrapedData>(it.readText()) }
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
}
}
},
receiptFiles = receiptFiles, receiptFiles = receiptFiles,
receiptAnalysisResult = receiptAnalysisResult, receiptAnalysisResult = receiptAnalysisResult,
onUploadReceipt = { isReceiptDialogVisible = true }, onUploadReceipt = { isReceiptDialogVisible = true },
onRemoveReceipt = { file -> receiptFiles = receiptFiles - file }, onRemoveReceipt = { file -> receiptFiles = receiptFiles - file },
isAnalyzing = analysisJob?.isActive == true,
onAnalyzeReceipts = { onAnalyzeReceipts = {
coroutineScope.launch(Dispatchers.IO) { analysisJob = coroutineScope.launch(Dispatchers.IO) {
isLoading = true try {
// [⭐️ 수정] 영수증 분석 함수 호출 시, 'receiptWorkspaceSlug' 값을 전달 isLoading = true
analyzeReceiptsWithStreamChat(receiptFiles, apiKey, receiptWorkspaceSlug, logMessages, receiptAnalysisResultFlow) val resultFlow = MutableStateFlow("")
isLoading = false 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) 1 -> LogTab(logMessages)
2 -> ResultTab(blogPostResult) 2 -> ResultTab(blogPostResult)
@ -728,155 +398,46 @@ fun WorkflowTab(
receiptAnalysisResult: String, receiptAnalysisResult: String,
onUploadReceipt: () -> Unit, onUploadReceipt: () -> Unit,
onRemoveReceipt: (File) -> Unit, onRemoveReceipt: (File) -> Unit,
onAnalyzeReceipts: () -> Unit onAnalyzeReceipts: () -> Unit,
isAnalyzing: Boolean,
onCancelAnalysis: () -> Unit,
receiptContextPrompt: String,
onReceiptContextPromptChange: (String) -> Unit
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(4.dp)) { Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(4.dp)) {
Row(modifier = Modifier.fillMaxWidth()) { 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)) } }
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)) { Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) {
Text("검색 결과", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) Text("검색 결과", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); LazyColumn(modifier = Modifier.fillMaxSize()) { 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) } } }
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.weight(3f).padding(horizontal = 4.dp).verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) { Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) { Text("저장된 파일 (스크랩 기반)", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp)); 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) } } }
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)) Spacer(Modifier.height(16.dp))
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) { Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Text("직접 작성 (여행 기록 등)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) Text("직접 작성 (여행 기록 등)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); 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) } } }
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)) Spacer(Modifier.height(16.dp))
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) { Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Text("영수증 분석기", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) Text("영수증 분석기", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)); 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 = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("영수증 이미지 업로드") } OutlinedTextField(value = receiptContextPrompt, onValueChange = onReceiptContextPromptChange, modifier = Modifier.fillMaxWidth(), placeholder = { Text("추가 정보를 입력하면 더 정확하게 분석할 수 있습니다.") }, label = { Text("추가 정보 입력 (예: 부산 출장 경비)") })
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) { Spacer(Modifier.height(8.dp));
items(receiptFiles) { file -> if (isAnalyzing) {
Box(modifier = Modifier.padding(4.dp)) { 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("분석 중단하기") } }
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("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 { } else {
"직접 작성 기반 글 생성" Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { Text("선택한 영수증 분석 시작 (${receiptFiles.size}개)") }
} }
Text(buttonText) 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) { if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
} }
} }
@ -887,19 +448,16 @@ fun LogTab(logs: List<String>) {
@Composable @Composable
fun ResultTab(result: String) { fun ResultTab(result: String) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { Text(result) }
Text(result)
}
} }
fun main() = application { fun main() = application {
val imageLoader = ImageLoader.Builder(coil3.PlatformContext.INSTANCE) val imageLoader = ImageLoader.Builder(coil3.PlatformContext.INSTANCE)
.components { add(OkHttpNetworkFetcherFactory()) } .components { add(OkHttpNetworkFetcherFactory()) }
.build() .build()
Window( Window(
onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() }, onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() },
title = "자동 블로그 포스팅 도우미 v7.1 (Receipt-Workspace-Separated)" title = "자동 블로그 포스팅 도우미 v9.0 (Context-Aware)"
) { ) {
App(imageLoader) App(imageLoader)
} }