....
This commit is contained in:
parent
e826522192
commit
c016328f1d
@ -17,9 +17,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
// ⭐️ [수정] Coil 3 import 경로 수정
|
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
//import coil3.compose.LocalImageLoader
|
|
||||||
import coil3.compose.rememberAsyncImagePainter
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
@ -33,7 +31,10 @@ 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.readUTF8Line
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
@ -43,6 +44,8 @@ import org.openqa.selenium.By
|
|||||||
import org.openqa.selenium.WebDriverException
|
import org.openqa.selenium.WebDriverException
|
||||||
import org.openqa.selenium.chrome.ChromeDriver
|
import org.openqa.selenium.chrome.ChromeDriver
|
||||||
import org.openqa.selenium.chrome.ChromeOptions
|
import org.openqa.selenium.chrome.ChromeOptions
|
||||||
|
import java.awt.FileDialog
|
||||||
|
import java.awt.Frame
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@ -50,14 +53,33 @@ import java.util.prefs.Preferences
|
|||||||
|
|
||||||
// --- 데이터 클래스 정의 ---
|
// --- 데이터 클래스 정의 ---
|
||||||
@Serializable data class AnythingLLMChatRequest(val message: String)
|
@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 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 sourceUrl: String,
|
||||||
val selectedImageUrl: String?,
|
val selectedImageUrls: List<String>,
|
||||||
val allImageUrls: List<String>,
|
val allImageUrls: List<String>,
|
||||||
val content: String
|
val content: String
|
||||||
)
|
)
|
||||||
@ -65,14 +87,17 @@ data class ScrapedData(
|
|||||||
// --- 전역 변수 및 헬퍼 ---
|
// --- 전역 변수 및 헬퍼 ---
|
||||||
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 = 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 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_MODEL_NAME = "model_name"
|
private const val PREF_MODEL_NAME = "model_name"
|
||||||
|
|
||||||
fun logMessage(logs: MutableList<String>, message: String) {
|
fun logMessage(logs: MutableList<String>, message: String) {
|
||||||
@ -92,7 +117,7 @@ private fun quitChromeDriver() {
|
|||||||
driver = null
|
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"
|
||||||
@ -145,7 +170,7 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowser
|
|||||||
currentDriver.get(url)
|
currentDriver.get(url)
|
||||||
Thread.sleep(2000)
|
Thread.sleep(2000)
|
||||||
val doc = Jsoup.parse(currentDriver.pageSource)
|
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")
|
val allImages = doc.select("article img, .article-body img, #article_body img, .news-article-body-view img")
|
||||||
.map { it.absUrl("src") }
|
.map { it.absUrl("src") }
|
||||||
.filter { it.isNotBlank() && it.startsWith("http") }
|
.filter { it.isNotBlank() && it.startsWith("http") }
|
||||||
@ -158,7 +183,7 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowser
|
|||||||
logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)")
|
logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)")
|
||||||
ScrapedData(
|
ScrapedData(
|
||||||
sourceUrl = url,
|
sourceUrl = url,
|
||||||
selectedImageUrl = allImages.firstOrNull(),
|
selectedImageUrls = allImages.take(1),
|
||||||
allImageUrls = allImages,
|
allImageUrls = allImages,
|
||||||
content = articleContent
|
content = articleContent
|
||||||
)
|
)
|
||||||
@ -202,28 +227,54 @@ suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKe
|
|||||||
return uploadedDocIds
|
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 블로그 글 생성 요청...")
|
logMessage(logs, "LLM 블로그 글 생성 요청...")
|
||||||
val chosenImage = scrapedDataList.firstNotNullOfOrNull { it.selectedImageUrl }
|
|
||||||
val referencesText = scrapedDataList.mapIndexed { index, data ->
|
val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) {
|
||||||
"[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n"
|
val referencesText = scrapedDataList.mapIndexed { index, data ->
|
||||||
}.joinToString("\n")
|
"[참고자료 ${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 -> "" }.joinToString("\n")
|
||||||
|
"4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n$imageMarkdown"
|
||||||
|
} else ""
|
||||||
|
|
||||||
val finalPrompt = """
|
val finalPrompt = """
|
||||||
당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다.
|
당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다.
|
||||||
아래 '참고자료'와 '요청사항'을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요.
|
아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요.
|
||||||
|
|
||||||
--- 참고자료 ---
|
$contentSourcePromptPart
|
||||||
$referencesText
|
|
||||||
|
|
||||||
--- 요청사항 ---
|
--- 요청사항 ---
|
||||||
1. 사용자 요청: "$userDirection"
|
1. 사용자 요청: "$userDirection"
|
||||||
2. 참고자료의 내용을 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.
|
2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.
|
||||||
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요.
|
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요.
|
||||||
${if (chosenImage != null) "4. 글의 적절한 위치에 마크다운 형식 ``으로 이미지를 삽입해주세요." else ""}
|
$imagePromptPart
|
||||||
5. 글 마지막에 출처에 대한 언급은 절대 하지 마세요.
|
5. 글 마지막에 출처에 대한 언급은 절대 하지 마세요.
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
// 텍스트 기반 생성은 RAG가 가능한 chat API를, 이미지(영수증) 분석은 Vision이 가능한 chat API를 사용합니다.
|
||||||
|
// 여기서는 텍스트 기반 글 생성이므로 기존 `AnythingLLMChatRequest`를 사용합니다.
|
||||||
val requestBody = AnythingLLMChatRequest(message = finalPrompt)
|
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") {
|
||||||
@ -235,7 +286,7 @@ suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, use
|
|||||||
logMessage(logs, "LLM 응답 수신...")
|
logMessage(logs, "LLM 응답 수신...")
|
||||||
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(responseBodyText)
|
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}"
|
||||||
@ -256,6 +307,116 @@ suspend fun cleanupLLMWorkspace(docIds: List<String>, logs: MutableList<String>,
|
|||||||
} catch (e: Exception) { logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}") }
|
} 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 {
|
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ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
|
||||||
@ -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 컴포넌트 ---
|
// --- UI 컴포넌트 ---
|
||||||
@Composable
|
@Composable
|
||||||
fun App(imageLoader: ImageLoader) {
|
fun App(imageLoader: ImageLoader) {
|
||||||
@ -298,11 +477,13 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
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(false) }
|
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")) }
|
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my-workspace")) }
|
||||||
var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "gpt-4o")) }
|
// [⭐️ 추가] 영수증 처리용 슬러그 상태 변수 추가, 기본값 "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 scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
|
||||||
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
|
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
|
||||||
@ -310,14 +491,53 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
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 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) { scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath) }
|
||||||
LaunchedEffect(scrapedFolderPath) { prefs.put(PREF_FOLDER_PATH, 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(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
|
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 {
|
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)) {
|
||||||
@ -330,13 +550,17 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
Spacer(Modifier.height(4.dp))
|
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()) {
|
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))
|
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))
|
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()
|
Divider()
|
||||||
TabRow(selectedTabIndex = tabIndex) {
|
TabRow(selectedTabIndex = tabIndex) {
|
||||||
@ -345,20 +569,28 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
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,
|
||||||
viewedFileContent = viewedFileContent, imagesForSelection = imagesForSelection, currentSelectedImage = currentSelectedImage,
|
viewedFileContent = viewedFileContent, imagesForSelection = imagesForSelection, currentSelectedImages = currentSelectedImages,
|
||||||
userPrompt = userPrompt, onUserPromptChange = { userPrompt = it }, imageLoader = imageLoader,
|
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 } },
|
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 ->
|
onSearchResultSelect = { result ->
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)?.let { data ->
|
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)
|
scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
|
||||||
currentlyOpenFile = savedFile
|
currentlyOpenFile = savedFile
|
||||||
viewedFileContent = data.content
|
viewedFileContent = data.content
|
||||||
imagesForSelection = data.allImageUrls
|
imagesForSelection = data.allImageUrls
|
||||||
currentSelectedImage = data.selectedImageUrl
|
currentSelectedImages = data.selectedImageUrls.toSet()
|
||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
@ -372,55 +604,105 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
currentlyOpenFile = file
|
currentlyOpenFile = file
|
||||||
viewedFileContent = data.content
|
viewedFileContent = data.content
|
||||||
imagesForSelection = data.allImageUrls
|
imagesForSelection = data.allImageUrls
|
||||||
currentSelectedImage = data.selectedImageUrl
|
currentSelectedImages = data.selectedImageUrls.toSet()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}")
|
logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onImageSelect = { imageUrl -> currentSelectedImage = imageUrl },
|
onImageSelect = { imageUrl ->
|
||||||
|
currentSelectedImages = if (imageUrl in currentSelectedImages) {
|
||||||
|
currentSelectedImages - imageUrl
|
||||||
|
} else {
|
||||||
|
currentSelectedImages + imageUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
onSaveChanges = {
|
onSaveChanges = {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
currentlyOpenFile?.let { file ->
|
currentlyOpenFile?.let { file ->
|
||||||
try {
|
try {
|
||||||
val originalData = jsonParser.decodeFromString<ScrapedData>(file.readText())
|
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))
|
file.writeText(jsonParser.encodeToString(updatedData))
|
||||||
logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.")
|
logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.")
|
||||||
} catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") }
|
} catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onUploadImage = { isImageUploadDialogVisible = true },
|
||||||
|
onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove },
|
||||||
onGeneratePost = {
|
onGeneratePost = {
|
||||||
if (apiKey.isBlank() || workspaceSlug.isBlank() || modelName.isBlank()) {
|
if (apiKey.isBlank() || workspaceSlug.isBlank()) {
|
||||||
logMessage(logMessages, "⚠️ API 키, 슬러그, 모델 이름을 모두 입력해주세요.")
|
logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요.")
|
||||||
return@WorkflowTab
|
return@WorkflowTab
|
||||||
}
|
}
|
||||||
if (selectedFiles.isNotEmpty()) {
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
var uploadedDocIds: List<String> = emptyList()
|
try {
|
||||||
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
|
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
|
||||||
try {
|
val uploadedImageUrls = uploadedImageFiles.map { it.toURI().toString() }
|
||||||
uploadedDocIds = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug)
|
|
||||||
if (uploadedDocIds.isNotEmpty()) {
|
val isScrapBased = selectedFiles.isNotEmpty()
|
||||||
val resultText = generateBlogPostWithLocalLLM(selectedDataList, userPrompt, logMessages, apiKey, workspaceSlug)
|
val finalContent = if(isScrapBased) selectedDataList else emptyList()
|
||||||
val footer = buildString {
|
val finalUserContent = if(!isScrapBased) userOwnContent else ""
|
||||||
appendLine("\n\n---")
|
|
||||||
appendLine("- 원문 출처:")
|
val allImages = if(isScrapBased) {
|
||||||
selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") }
|
selectedDataList.flatMap { it.selectedImageUrls }.distinct()
|
||||||
selectedDataList.firstNotNullOfOrNull { it.selectedImageUrl }?.let { appendLine("- 이미지 출처: $it") }
|
} else {
|
||||||
appendLine("- 이 글은 ${modelName} 모델을 활용하여 작성되었습니다.")
|
uploadedImageUrls
|
||||||
}
|
|
||||||
blogPostResult = resultText + footer
|
|
||||||
tabIndex = 2
|
|
||||||
} else { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.") }
|
|
||||||
} finally {
|
|
||||||
cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey)
|
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
1 -> LogTab(logMessages)
|
||||||
@ -433,17 +715,46 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun WorkflowTab(
|
fun WorkflowTab(
|
||||||
isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>, scrapedFiles: List<File>, selectedFiles: Set<File>,
|
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,
|
onUserPromptChange: (String) -> Unit, onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit,
|
||||||
onSearchResultSelect: (SearchResult) -> Unit, onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> 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()) {
|
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()) {
|
||||||
|
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("트렌드 가져오기") }
|
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)) {
|
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()) {
|
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) {
|
Column(modifier = Modifier.weight(3f).padding(horizontal = 4.dp).verticalScroll(rememberScrollState())) {
|
||||||
Text("저장된 파일", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
|
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
|
||||||
Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") }
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
}
|
Text("저장된 파일 (스크랩 기반)", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") }
|
||||||
items(scrapedFiles) { file ->
|
}
|
||||||
Row(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
Box(modifier = Modifier.heightIn(max = 200.dp)) {
|
||||||
Checkbox(checked = file in selectedFiles, onCheckedChange = { isChecked -> onFileSelectToggle(file, isChecked) }, enabled = !isLoading)
|
LazyColumn {
|
||||||
Text(file.name, modifier = Modifier.padding(start = 4.dp), maxLines = 1)
|
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)) {
|
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)
|
Text("LLM 요청사항", style = MaterialTheme.typography.h6)
|
||||||
TextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), placeholder = { Text("예: 선택된 파일들을 종합해서...") })
|
TextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), placeholder = { Text("예: 선택된 파일들을 종합해서...") })
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
Button(
|
||||||
Text("블로그 글 생성하기 (${selectedFiles.size}개 파일)")
|
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 {
|
fun main() = application {
|
||||||
// ⭐️ [Coil 3 변경] 데스크톱용 ImageLoader 생성
|
|
||||||
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 = "자동 블로그 포스팅 도우미 v4.3"
|
title = "자동 블로그 포스팅 도우미 v7.1 (Receipt-Workspace-Separated)"
|
||||||
) {
|
) {
|
||||||
// ❌ [삭제] CompositionLocalProvider는 더 이상 사용하지 않습니다.
|
|
||||||
// CompositionLocalProvider(LocalImageLoader provides imageLoader) {
|
|
||||||
// App()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ✅ [수정] App 함수에 imageLoader를 파라미터로 직접 전달합니다.
|
|
||||||
App(imageLoader)
|
App(imageLoader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user