Compare commits

..
8 Commits
Author SHA1 Message Date
lun_admin 161c00c7b9 ... 2025-10-13 16:54:39 +09:00
lun_admin 92aa1a998e ... 2025-10-13 16:18:55 +09:00
lun_admin 6d66a0147c ... 2025-10-13 13:26:39 +09:00
lun_admin a58bbc8fee ... 2025-10-02 16:56:09 +09:00
lun_admin c016328f1d .... 2025-10-02 16:09:23 +09:00
lun_admin e826522192 ... 2025-10-01 18:45:48 +09:00
lun_admin 871741b764 ... 2025-10-01 17:56:25 +09:00
lun_admin b95aa050ad ... 2025-10-01 17:48:44 +09:00
10 changed files with 1650 additions and 466 deletions
+10
View File
@@ -17,6 +17,9 @@ kotlin {
repositories {
mavenCentral()
maven {
url = uri("https://company/com/maven2")
}
google()
maven("https://maven.datlag.dev/snapshots")
}
@@ -25,6 +28,13 @@ dependencies {
// Jetpack Compose for Desktop UI
implementation(compose.desktop.currentOs)
// ⭐️ [추가] 이미지 로딩 라이브러리 Coil
// ⭐️ [추가] 새로운 Coil 3 라이브러리
implementation("io.coil-kt.coil3:coil-compose-core:3.0.0-alpha06")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.0-alpha06")
// Coil 3의 OkHttp Fetcher는 OkHttp 라이브러리가 별도로 필요합니다.
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0")
// Ktor for HTTP Client (LLM API 호출용)
val ktorVersion = "2.3.12"
implementation("io.ktor:ktor-client-cio:$ktorVersion")
+13 -466
View File
@@ -1,475 +1,22 @@
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
// Main.kt
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
import org.openqa.selenium.By
import org.openqa.selenium.chrome.ChromeDriver
import org.openqa.selenium.chrome.ChromeOptions
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import kotlin.io.path.Path
import kotlin.io.path.readText
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import ui.App
import utils.BrowserManager.quitChromeDriver
import utils.Global.httpClient
import utils.Strings
// --- 데이터 클래스 정의 ---
@Serializable data class AnythingLLMChatRequest(val message: String)
@Serializable data class AnythingLLMChatResponse(val textResponse: String)
data class SearchResult(val title: String, val url: String)
// --- 전역 변수 및 헬퍼 ---
private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { isLenient = true; ignoreUnknownKeys = true })
}
}
fun logMessage(logs: MutableList<String>, message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
logs.add(0, "$timestamp: $message")
}
// --- 핵심 기능 함수들 ---
suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean): List<String> {
logMessage(logs, "Google Trends 페이지 스크랩 시작...")
val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR"
val options = ChromeOptions().apply {
if (!isBrowserVisible) {
addArguments("--headless=new")
}
addArguments("--disable-gpu")
}
val driver = ChromeDriver(options)
val keywords = mutableListOf<String>()
return try {
driver.get(trendsUrl)
Thread.sleep(2000)
val elements = driver.findElements(By.xpath("//tr[count(td)=7]/td[2]"))
for (element in elements) {
val keywordText = element.text
if (keywordText.isNotBlank()) {
keywords.add(keywordText)
}
}
logMessage(logs, "✅ Google Trends 키워드 ${keywords.size}개 스크랩 완료.")
keywords
} catch (e: Exception) {
logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}")
e.printStackTrace()
emptyList()
} finally {
driver.quit()
}
}
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean): List<SearchResult> {
logMessage(logs, "'$keyword' 키워드로 Google 검색 시작...")
val options = ChromeOptions().apply {
if (!isBrowserVisible) {
addArguments("--headless=new")
}
addArguments("--disable-gpu")
}
val driver = ChromeDriver(options)
val results = mutableListOf<SearchResult>()
try {
driver.get("https://www.google.com/search?q=$keyword")
Thread.sleep(2000)
val resultElements = driver.findElements(By.cssSelector("div[data-rpos]"))
for (element in resultElements.take(10)) {
try {
val titleElement = element.findElement(By.cssSelector("h3"))
val linkElement = element.findElement(By.cssSelector("a"))
val title = titleElement.text
val url = linkElement.getAttribute("href")
if (title.isNotBlank() && url.isNotBlank()) {
results.add(SearchResult(title, url))
}
} catch (e: Exception) {
// 개별 검색 결과 파싱 오류는 무시
}
}
logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.")
} catch (e: Exception) {
logMessage(logs, "❌ Google 검색 중 오류: ${e.message}")
e.printStackTrace()
} finally {
driver.quit()
}
return results
}
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean): String {
logMessage(logs, "URL 스크랩 시작: $url")
val options = ChromeOptions().apply {
if (!isBrowserVisible) {
addArguments("--headless=new")
}
addArguments("--disable-gpu")
}
val driver = ChromeDriver(options)
return try {
driver.get(url)
Thread.sleep(2000)
val finalHtml = driver.pageSource
val doc = Jsoup.parse(finalHtml)
val articleContent = doc.select("article, .article-body, #article_body, .news-article-body-view").text()
if (articleContent.isBlank()) {
logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.")
"기사 본문을 찾을 수 없습니다."
} else {
logMessage(logs, "✅ URL 스크랩 완료. (총 ${articleContent.length}자)")
articleContent
}
} catch (e: Exception) {
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}")
e.printStackTrace()
"페이지 스크랩 중 오류 발생: ${e.message}"
} finally {
driver.quit()
}
}
suspend fun generateBlogPostWithLocalLLM(fileNames: List<String>, userDirection: String, logs: MutableList<String>): String {
logMessage(logs, "LLM 블로그 글 생성 요청 (${fileNames.size}개 파일 기반)...")
val apiKey = System.getenv("ANYTHINGLLM_API_KEY") ?: "YOUR_API_KEY_HERE"
val fileNamesString = fileNames.joinToString(", ")
val finalPrompt = """
내 지식 베이스에 있는 다음 문서들을 종합적으로 참고해서 아래 '요청사항'에 맞춰 SEO에 최적화된 블로그 글을 작성해줘. 제목도 2~3개 추천해줘.
--- 참고 문서 ---
$fileNamesString
--- 요청사항 ---
$userDirection
""".trimIndent()
val requestBody = AnythingLLMChatRequest(message = finalPrompt)
return try {
val response: AnythingLLMChatResponse = httpClient.post("http://localhost:3001/api/v1/workspace/my/chat") {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(requestBody)
}.body()
logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
response.textResponse
} catch (e: Exception) {
logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}")
e.printStackTrace()
"블로그 글 생성 실패: ${e.message}"
}
}
fun saveContentToFile(keyword: String, content: String, logs: MutableList<String>) {
try {
val directory = File("/Users/jibumhan/autoblog_content")
if (!directory.exists()) directory.mkdirs()
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.txt"
val file = File(directory, fileName)
file.writeText(content)
logMessage(logs, "✅ '${file.path}'에 스크랩 내용 저장 완료.")
} catch (e: Exception) {
logMessage(logs, "❌ 파일 저장 중 오류: ${e.message}")
e.printStackTrace()
}
}
fun loadScrapedFiles(logs: MutableList<String>): List<File> {
logMessage(logs, "스크랩된 파일 목록 로딩...")
return try {
val directory = File("scraped_articles")
if (!directory.exists() || !directory.isDirectory) {
logMessage(logs, "⚠️ 'scraped_articles' 폴더를 찾을 수 없습니다.")
return emptyList()
}
val files = directory.listFiles { _, name -> name.endsWith(".txt") }
?.sortedByDescending { it.lastModified() }
?: emptyList()
logMessage(logs, "✅ 파일 ${files.size}개 로딩 완료.")
files
} catch (e: Exception) {
logMessage(logs, "❌ 파일 로딩 중 오류: ${e.message}")
emptyList()
}
}
// --- UI 컴포넌트 ---
@Composable
fun App() {
var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("워크플로우", "통신 로그", "블로그 결과")
val coroutineScope = rememberCoroutineScope()
// --- 상태 관리 ---
var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
val logMessages = remember { mutableStateListOf<String>() }
var isLoading by remember { mutableStateOf(false) }
var selectedKeyword by remember { mutableStateOf("") }
var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") }
var isBrowserVisible by remember { mutableStateOf(true) } // 브라우저 가시성 상태
// 파일 관리 상태
var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
// 앱 시작 시 파일 목록 로드
LaunchedEffect(Unit) {
scrapedFiles = loadScrapedFiles(logMessages)
}
MaterialTheme {
Column(modifier = Modifier.fillMaxSize()) {
// --- 전역 설정 (체크박스) ---
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isBrowserVisible,
onCheckedChange = { isBrowserVisible = it }
)
Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible })
}
Divider()
TabRow(selectedTabIndex = tabIndex) {
tabs.forEachIndexed { index, title ->
Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index })
}
}
when (tabIndex) {
0 -> WorkflowTab(
isLoading = isLoading,
keywords = keywords,
searchResults = searchResults,
scrapedFiles = scrapedFiles,
selectedFiles = selectedFiles,
viewedFileContent = viewedFileContent,
userPrompt = userPrompt,
onUserPromptChange = { userPrompt = it },
onFetchTrends = {
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
keywords = fetchGoogleTrends(logMessages, isBrowserVisible)
isLoading = false
}
},
onKeywordSelect = { keyword ->
selectedKeyword = keyword
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible)
isLoading = false
}
},
onSearchResultSelect = { result ->
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
val content = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible)
if (!content.contains("오류 발생")) {
saveContentToFile(selectedKeyword, content, logMessages)
scrapedFiles = loadScrapedFiles(logMessages) // 저장 후 목록 새로고침
}
isLoading = false
}
},
onRefreshFiles = {
coroutineScope.launch(Dispatchers.IO) {
scrapedFiles = loadScrapedFiles(logMessages)
}
},
onFileSelectToggle = { file, isSelected ->
selectedFiles = if (isSelected) {
selectedFiles + file
} else {
selectedFiles - file
}
},
onFileView = { file ->
coroutineScope.launch(Dispatchers.IO) {
try {
viewedFileContent = Path(file.absolutePath).readText(Charsets.UTF_8)
} catch (e: Exception) {
logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}")
viewedFileContent = "파일을 읽는 중 오류가 발생했습니다."
}
}
},
onGeneratePost = {
if (selectedFiles.isNotEmpty()) {
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
val fileNames = selectedFiles.map { it.name }
blogPostResult = generateBlogPostWithLocalLLM(fileNames, userPrompt, logMessages)
tabIndex = 2 // 결과 탭으로 이동
isLoading = false
}
} else {
logMessage(logMessages, "⚠️ 블로그 글을 생성할 파일을 선택해주세요.")
}
}
)
1 -> LogTab(logMessages)
2 -> ResultTab(blogPostResult)
}
}
}
}
@Composable
fun WorkflowTab(
isLoading: Boolean,
keywords: List<String>,
searchResults: List<SearchResult>,
scrapedFiles: List<File>,
selectedFiles: Set<File>,
viewedFileContent: String,
userPrompt: String,
onUserPromptChange: (String) -> Unit,
onFetchTrends: () -> Unit,
onKeywordSelect: (String) -> Unit,
onSearchResultSelect: (SearchResult) -> Unit,
onRefreshFiles: () -> Unit,
onFileSelectToggle: (File, Boolean) -> Unit,
onFileView: (File) -> Unit,
onGeneratePost: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
// 1열: 트렌드 키워드
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(4.dp)) {
Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) {
Text("트렌드 가져오기")
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(keywords) { keyword ->
Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp))
}
}
}
// 2열: 검색 결과
Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) {
Text("검색 결과", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(searchResults) { result ->
Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) {
Text(result.title, style = MaterialTheme.typography.subtitle1, color = MaterialTheme.colors.primary)
Text(result.url, style = MaterialTheme.typography.caption, maxLines = 1)
}
}
}
}
// 3열: 스크랩된 파일 목록
Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("저장된 파일", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") }
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(scrapedFiles) { file ->
Row(
modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = file in selectedFiles,
onCheckedChange = { isChecked -> onFileSelectToggle(file, isChecked) },
enabled = !isLoading
)
Text(file.name, modifier = Modifier.padding(start = 4.dp), maxLines = 1)
}
}
}
}
// 4열: 내용 확인 및 생성
Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(8.dp)) {
Text("파일 내용", style = MaterialTheme.typography.h6)
Text(
text = viewedFileContent,
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).border(1.dp, Color.LightGray).padding(4.dp),
style = MaterialTheme.typography.body2
)
Spacer(Modifier.height(8.dp))
Text("LLM 요청사항", style = MaterialTheme.typography.h6)
TextField(
value = userPrompt,
onValueChange = onUserPromptChange,
modifier = Modifier.fillMaxWidth().height(100.dp),
placeholder = { Text("예: 선택된 파일들을 종합해서...") }
)
Spacer(Modifier.height(8.dp))
Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
Text("블로그 글 생성하기 (${selectedFiles.size}개 파일)")
}
}
}
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
@Composable
fun LogTab(logs: List<String>) {
TextField(
value = logs.joinToString("\n"),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxSize().padding(8.dp)
)
}
@Composable
fun ResultTab(result: String) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
Text(result)
}
}
// --- 애플리케이션 시작점 ---
fun main() = application {
val imageLoader = ImageLoader.Builder(coil3.PlatformContext.INSTANCE)
.components { add(OkHttpNetworkFetcherFactory()) }
.build()
Window(
onCloseRequest = {
httpClient.close()
exitApplication()
},
title = "자동 블로그 포스팅 도우미 v2.6"
onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() },
title = Strings.APP_TITLE
) {
App()
App(imageLoader)
}
}
+202
View File
@@ -0,0 +1,202 @@
// core/LlmApiService.kt
package core
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.utils.io.*
import kotlinx.coroutines.flow.MutableStateFlow
import models.*
import utils.Global
import utils.Global.httpClient
import utils.Global.jsonParser
import utils.Strings
import utils.logMessage
import java.io.File
import java.util.*
import kotlin.coroutines.cancellation.CancellationException
object LlmApiService {
suspend fun uploadFiles(files: List<File>, apiKey: String, workspaceSlug: String, logs: MutableList<String>): List<String> {
logMessage(logs, Strings.logLlmUploadStarting(files.size))
val uploadedDocIds = mutableListOf<String>()
files.forEach { file ->
logMessage(logs, Strings.logLlmUploadingFile(file.name))
try {
val response: HttpResponse = Global.httpClient.post("http://localhost:3001/api/v1/document/upload") {
header("Authorization", "Bearer $apiKey")
setBody(MultiPartFormDataContent(formData {
append("addToWorkspaces", workspaceSlug)
val scrapedData = Global.jsonParser.decodeFromString<ScrapedData>(file.readText())
append("file", scrapedData.content.toByteArray(), Headers.build { append(HttpHeaders.ContentType, ContentType.Text.Plain); append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") })
}))
}
if (response.status.isSuccess()) {
val uploadResponse = response.body<UploadResponse>()
uploadResponse.documents.firstOrNull()?.id?.let {
uploadedDocIds.add(it); logMessage(logs, Strings.logLlmUploadSuccess(file.name, it))
} ?: logMessage(logs, Strings.logLlmUploadResponseError(file.name, "", Strings.LOG_LLM_UPLOAD_NO_DOC_INFO))
} else { logMessage(logs, Strings.logLlmUploadResponseError(file.name, response.status, response.bodyAsText())) }
} catch (e: Exception) { logMessage(logs, Strings.logLlmUploadException(file.name, e.message)) }
}
logMessage(logs, Strings.logLlmUploadFinished(files.size, uploadedDocIds.size))
return uploadedDocIds
}
suspend fun generateBlogPost(
scrapedDataList: List<ScrapedData>,
userOwnContent: String,
allSelectedImages: List<String>,
userDirection: String,
userScrapComment: String,
userMainTopic: String,
apiKey: String,
workspaceSlug: String,
generatePromptPrefix: String,
generatePromptInstructions: List<String>, // ⭐️ [추가] 요청사항 리스트 인자
logs: MutableList<String>
): String {
logMessage(logs, Strings.LOG_LLM_POST_GENERATION_START)
val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) {
scrapedDataList.mapIndexed { index, data -> "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" }.joinToString("\n").let { "--- 참고자료 ---\n$it" }
} else {
"--- 주요 내용 ---\n아래 내용을 바탕으로 글을 작성해주세요.\n$userOwnContent"
}
val commentPromptPart = if (userScrapComment.isNotBlank()) {
"--- 작성자 코멘트 ---\n$userScrapComment\n이 코멘트의 논조와 생각을 반영하여 글을 작성해주세요.\n"
} else ""
val mainTopicPromptPart = if (userMainTopic.isNotBlank()) {
"--- 글의 핵심 주제 ---\n$userMainTopic\n이 주제가 글의 중심 내용이 되도록 작성해주세요.\n"
} else ""
val imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "![이미지 ${index + 1}]($url)" }.joinToString("\n")}" else ""
// ⭐️ [수정] Strings.Prompts.generateBlogPost 호출 시 인자 추가
val finalPrompt = Strings.Prompts.generateBlogPost(
basePrompt = generatePromptPrefix,
userDirection = userDirection,
instructions = generatePromptInstructions,
contentSourcePromptPart = contentSourcePromptPart,
commentPromptPart = commentPromptPart,
mainTopicPromptPart = mainTopicPromptPart,
imagePromptPart = imagePromptPart
)
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
}
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
logMessage(logs, Strings.LOG_LLM_POST_GENERATION_SUCCESS)
return chatResponse.textResponse
} catch (e: Exception) {
logMessage(logs, Strings.logLlmApiError(e.message)); return "블로그 글 생성 실패: ${e.message}"
}
}
suspend fun reviseBlogPost(
currentPost: String,
revisionRequest: String,
apiKey: String,
workspaceSlug: String,
revisePromptPrefix: String, // ⭐️ [수정] 프롬프트 인자 추가
logs: MutableList<String>
): String {
logMessage(logs, Strings.LOG_LLM_REVISION_START)
if (revisionRequest.isBlank()) {
logMessage(logs, Strings.LOG_LLM_REVISION_EMPTY)
return currentPost
}
// ⭐️ [수정] 인자로 받은 프롬프트를 사용
val finalPrompt = """
$revisePromptPrefix
--- 원본 글 ---
$currentPost
--- 수정 요청사항 ---
$revisionRequest
--- 수정된 글 ---
""".trimIndent()
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
}
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
logMessage(logs, Strings.LOG_LLM_REVISION_SUCCESS)
return chatResponse.textResponse
} catch (e: Exception) {
logMessage(logs, Strings.logLlmApiError(e.message)); return "블로그 글 수정 실패: ${e.message}"
}
}
suspend fun cleanupWorkspace(docIds: List<String>, apiKey: String, logs: MutableList<String>) {
if (docIds.isEmpty()) return
logMessage(logs, Strings.logLlmCleanupStart(docIds.size))
try {
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") {
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(DeleteDocumentsRequest(deletes = docIds))
}
if (response.status.isSuccess()) { logMessage(logs, Strings.LOG_LLM_CLEANUP_SUCCESS) }
else { logMessage(logs, Strings.logLlmCleanupError(response.status, response.bodyAsText())) }
} catch (e: Exception) { logMessage(logs, Strings.logLlmCleanupException(e.message)) }
}
suspend fun analyzeReceipts(
files: List<File>,
apiKey: String,
receiptWorkspaceSlug: String,
receiptContext: String,
receiptPromptBase: String, // ⭐️ [수정] 프롬프트 인자 추가
logs: MutableList<String>,
resultState: MutableStateFlow<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()
logMessage(logs, Strings.logReceiptAnalysisMissing(missing)); return
}
logMessage(logs, Strings.LOG_RECEIPT_ANALYSIS_START)
resultState.value = "영수증 분석 중..."
try {
val attachments = files.map { file -> val base64Image = Base64.getEncoder().encodeToString(file.readBytes()); val mimeType = when (file.extension.lowercase()) { "png" -> "image/png"; "jpeg", "jpg" -> "image/jpeg"; else -> "application/octet-stream" }; Attachment(file.name, mimeType, "data:$mimeType;base64,$base64Image") }
// ⭐️ [수정] 인자로 받은 프롬프트를 사용
val finalPrompt = if (receiptContext.isNotBlank()) Strings.Prompts.receiptAnalysisWithContext(receiptPromptBase, receiptContext) else receiptPromptBase
logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}")
val requestBody = StreamChatRequest(message = finalPrompt, attachments = attachments, mode = "chat", sessionId = "receipt-analysis-${System.currentTimeMillis()}")
httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") {
accept(ContentType.Application.Json); header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(requestBody)
}.execute { response ->
if (response.status.isSuccess()) {
val channel: ByteReadChannel = response.bodyAsChannel()
var fullResponseText = ""
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: continue
if (line.startsWith("data:")) {
val jsonChunk = line.removePrefix("data:").trim()
if (jsonChunk.isEmpty() || jsonChunk == "[DONE]") continue
try {
when (val streamObject = jsonParser.decodeFromString<SealedLLMStreamResponse>(jsonChunk)) {
is SealedLLMStreamResponse.TextResponseChunk -> { val textChunk = streamObject.textResponse ?: ""; if (textChunk.isNotBlank() && textChunk != "-") { fullResponseText += textChunk; resultState.value = fullResponseText } }
is SealedLLMStreamResponse.FinalizeResponseStream -> { logMessage(logs, Strings.LOG_RECEIPT_STREAM_FINALIZED); streamObject.metrics?.let { logs.add(Strings.logReceiptStreamMetrics(it.totalTokens, it.duration)) }; break }
is SealedLLMStreamResponse.AbortResponse -> { logMessage(logs, Strings.logReceiptStreamAborted(streamObject.textResponse)); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break }
}
} catch (e: Exception) { logMessage(logs, Strings.logReceiptChunkParsingError(e.message, jsonChunk)) }
}
}
logMessage(logs, Strings.LOG_RECEIPT_STREAM_FINISHED)
} else { val errorBody = response.bodyAsText(); logMessage(logs, Strings.logReceiptApiError(response.status, errorBody)); resultState.value = "API 오류: ${response.status}" }
}
} catch (e: CancellationException) { throw e }
catch (e: Exception) { logMessage(logs, Strings.logReceiptException(e.message)); resultState.value = e.message ?: "알 수 없는 오류" }
}
}
+92
View File
@@ -0,0 +1,92 @@
// core/ScrapingService.kt
package core
import models.ScrapedData
import models.SearchResult
import org.jsoup.Jsoup
import org.openqa.selenium.By
import org.openqa.selenium.chrome.ChromeOptions
import utils.BrowserManager
import utils.Strings
import utils.logMessage
object ScrapingService {
suspend fun fetchGoogleTrends(logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<String> {
logMessage(logs, Strings.LOG_TRENDS_START)
val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR"
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options)
return try {
currentDriver.get(trendsUrl)
Thread.sleep(5000)
currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")).mapNotNull { it.text.takeIf(String::isNotBlank) }
.also { logMessage(logs, Strings.logTrendsSuccess(it.size)) }
} catch (e: Exception) {
logMessage(logs, Strings.logTrendsError(e.message)); BrowserManager.quitChromeDriver(); emptyList()
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
}
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> {
logMessage(logs, Strings.logSearchStart(keyword))
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options)
val results = mutableListOf<SearchResult>()
try {
currentDriver.get("https://www.google.com/search?q=$keyword")
Thread.sleep(10000)
currentDriver.findElements(By.cssSelector("div[data-rpos]")).take(10).forEach { element ->
try {
val title = element.findElement(By.cssSelector("h3")).text
val url = element.findElement(By.cssSelector("a")).getAttribute("href")
if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url))
} catch (e: Exception) { /* 개별 오류 무시 */ }
}
logMessage(logs, Strings.logSearchSuccess(keyword, results.size))
} catch (e: Exception) {
logMessage(logs, Strings.logSearchError(e.message)); BrowserManager.quitChromeDriver()
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
return results
}
suspend fun scrapeArticleByUrl(url: String, selectors: List<String>, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? {
logMessage(logs, Strings.logScrapeUrlStart(url))
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
val currentDriver = BrowserManager.getChromeDriver(options)
return try {
currentDriver.get(url)
Thread.sleep(4000)
val doc = Jsoup.parse(currentDriver.pageSource)
// ⭐️ [수정] 1. 셀렉터와 일치하는 '모든' 요소를 찾습니다.
val candidateElements = doc.select(selectors.joinToString(", "))
// ⭐️ [수정] 2. 찾은 요소들 중에서 텍스트 길이가 100자 이상인 첫 번째 요소를 본문으로 선택합니다.
val articleElement = candidateElements.find { it.text().length >= 100 }
if (articleElement == null) {
logMessage(logs, Strings.LOG_WARN_ARTICLE_BODY_NOT_FOUND)
return null
}
// 3. 찾은 요소에서 텍스트와 이미지를 각각 추출합니다.
val articleContent = articleElement.text()
val allImages = articleElement.select("img")
.map { it.absUrl("src") }
.filter { it.isNotBlank() && it.startsWith("http") }
.distinct()
if (articleContent.isBlank()) {
logMessage(logs, Strings.LOG_WARN_ARTICLE_BODY_NOT_FOUND)
null
} else {
logMessage(logs, Strings.logScrapeUrlSuccess(allImages.size))
ScrapedData(sourceUrl = url, selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent)
}
} catch (e: Exception) {
logMessage(logs, Strings.logScrapeUrlError(e.message)); BrowserManager.quitChromeDriver(); null
} finally {
if (!keepSession) BrowserManager.quitChromeDriver()
}
}
}
+36
View File
@@ -0,0 +1,36 @@
// models/ApiData.kt
package models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator
// --- Stream Chat API 응답 모델 ---
@Serializable
@JsonClassDiscriminator("type")
sealed interface SealedLLMStreamResponse {
@Serializable
@SerialName("textResponseChunk")
data class TextResponseChunk(val uuid: String, val textResponse: String?, val sources: List<Source> = emptyList(), val close: Boolean, val error: Boolean) : SealedLLMStreamResponse
@Serializable
@SerialName("finalizeResponseStream")
data class FinalizeResponseStream(val uuid: String, val chatId: Int, val metrics: Metrics?, val close: Boolean, val error: Boolean) : SealedLLMStreamResponse
@Serializable
@SerialName("abort")
data class AbortResponse(val uuid: String? = null, val textResponse: String? = null, val close: Boolean = true, val error: Boolean? = null) : SealedLLMStreamResponse
}
@Serializable data class Source(val id: String? = null, val text: String? = null, val location: String? = null, val distance: Float? = null, val score: Float? = null)
@Serializable data class Metrics(@SerialName("completion_tokens") val completionTokens: Int, @SerialName("prompt_tokens") val promptTokens: Int, @SerialName("total_tokens") val totalTokens: Int, val outputTps: Double? = null, val duration: Double? = null)
// --- 일반 Chat 및 문서 관리 API 모델 ---
@Serializable data class AnythingLLMChatRequest(val message: String)
@Serializable data class AnythingLLMChatResponse(val textResponse: String)
@Serializable data class UploadResponse(val documents: List<Document>) { @Serializable data class Document(val id: String, val location: String) }
@Serializable data class DeleteDocumentsRequest(val deletes: List<String>)
// --- Stream Chat API 요청 모델 ---
@Serializable data class Attachment(val name: String, val mime: String, @SerialName("contentString") val content: String)
@Serializable data class StreamChatRequest(val message: String, val attachments: List<Attachment>, val mode: String, val sessionId: String)
+14
View File
@@ -0,0 +1,14 @@
// models/AppData.kt
package models
import kotlinx.serialization.Serializable
data class SearchResult(val title: String, val url: String)
@Serializable
data class ScrapedData(
val sourceUrl: String,
val selectedImageUrls: List<String>,
val allImageUrls: List<String>,
val content: String
)
+433
View File
@@ -0,0 +1,433 @@
// ui/App.kt
package ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import coil3.ImageLoader
import core.LlmApiService.analyzeReceipts
import core.LlmApiService.cleanupWorkspace
import core.LlmApiService.generateBlogPost
import core.LlmApiService.reviseBlogPost
import core.LlmApiService.uploadFiles
import core.ScrapingService
import core.ScrapingService.fetchGoogleTrends
import core.ScrapingService.scrapeArticleByUrl
import core.ScrapingService.searchOnGoogle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import models.ScrapedData
import models.SearchResult
import ui.tabs.*
import ui.widgets.FileDialog
import utils.FileManager.loadScrapedJsonFiles
import utils.FileManager.saveDataToJsonFile
import utils.Global
import utils.Global.jsonParser
import utils.Strings
import utils.logMessage
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.io.File
import java.util.prefs.Preferences
import kotlin.coroutines.cancellation.CancellationException
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
const val PREF_FOLDER_PATH = Global.PREF_FOLDER_PATH
const val PREF_API_KEY = Global.PREF_API_KEY
const val PREF_WORKSPACE_SLUG = Global.PREF_WORKSPACE_SLUG
const val PREF_RECEIPT_WORKSPACE_SLUG = Global.PREF_RECEIPT_WORKSPACE_SLUG
const val PREF_MODEL_NAME = Global.PREF_MODEL_NAME
const val PREF_PROMPT_GENERATE = Global.PREF_PROMPT_GENERATE
const val PREF_PROMPT_REVISE = Global.PREF_PROMPT_REVISE
const val PREF_PROMPT_RECEIPT = Global.PREF_PROMPT_RECEIPT
const val PREF_PROMPT_GENERATE_INSTRUCTIONS = Global.PREF_PROMPT_GENERATE_INSTRUCTIONS
// ⭐️ [추가] 셀렉터 설정 키 import
const val PREF_ARTICLE_SELECTORS = Global.PREF_ARTICLE_SELECTORS
@Composable
fun App(imageLoader: ImageLoader) {
val coroutineScope = rememberCoroutineScope()
var tabIndex by remember { mutableStateOf(0) }
val tabs = Strings.TABS + "설정"
val logMessages = remember { mutableStateListOf<String>() }
var isLoading by remember { mutableStateOf(false) }
// --- 상태 변수 선언 ---
var blogPostResult by remember { mutableStateOf(Strings.PLACEHOLDER_BLOG_POST) }
var blogPostFooter by remember { mutableStateOf("") }
var generationContextForClipboard by remember { mutableStateOf("") }
var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
var selectedKeyword by remember { mutableStateOf("") }
var userPrompt by remember { mutableStateOf(Strings.DEFAULT_USER_PROMPT) }
var isBrowserVisible by remember { mutableStateOf(true) }
var keepBrowserSession by remember { mutableStateOf(true) }
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, Strings.DEFAULT_SCRAPED_FOLDER)) }
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, Strings.DEFAULT_WORKSPACE_SLUG)) }
var receiptWorkspaceSlug by remember { mutableStateOf(prefs.get(PREF_RECEIPT_WORKSPACE_SLUG, Strings.DEFAULT_RECEIPT_WORKSPACE_SLUG)) }
var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, Strings.DEFAULT_MODEL_NAME)) }
var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
var viewedFileContent by remember { mutableStateOf(Strings.PLACEHOLDER_FILE_CONTENT) }
var combinedImagesFromSelectedFiles by remember { mutableStateOf<List<String>>(emptyList()) }
var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) }
var manualKeyword by remember { mutableStateOf("") }
var userOwnContent by remember { mutableStateOf(Strings.DEFAULT_USER_OWN_CONTENT) }
var isImageUploadDialogVisible by remember { mutableStateOf(false) }
var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var receiptAnalysisResult by remember { mutableStateOf(Strings.PLACEHOLDER_RECEIPT_ANALYSIS) }
var isReceiptDialogVisible by remember { mutableStateOf(false) }
var analysisJob by remember { mutableStateOf<Job?>(null) }
var receiptContextPrompt by remember { mutableStateOf("") }
var userScrapComment by remember { mutableStateOf("") }
var userMainTopic by remember { mutableStateOf("") }
var revisionRequest by remember { mutableStateOf("") }
// --- 프롬프트 및 설정 상태 변수 ---
var generatePromptPrefix by remember { mutableStateOf(prefs.get(PREF_PROMPT_GENERATE, Strings.Prompts.GENERATE_POST_PREFIX)) }
var revisePromptPrefix by remember { mutableStateOf(prefs.get(PREF_PROMPT_REVISE, Strings.Prompts.REVISE_POST_PREFIX)) }
var receiptPromptBase by remember { mutableStateOf(prefs.get(PREF_PROMPT_RECEIPT, Strings.Prompts.RECEIPT_ANALYSIS_BASE)) }
var generatePromptInstructions by remember {
val saved = prefs.get(PREF_PROMPT_GENERATE_INSTRUCTIONS, null)
mutableStateOf(
saved?.split('\n')?.filter { it.isNotEmpty() } ?: Strings.Prompts.DEFAULT_GENERATE_POST_INSTRUCTIONS
)
}
// ⭐️ [추가] 스크래핑 셀렉터 상태 변수
var articleSelectors by remember {
val saved = prefs.get(PREF_ARTICLE_SELECTORS, null)
mutableStateOf(
saved?.split('\n')?.filter { it.isNotEmpty() } ?: Strings.DEFAULT_ARTICLE_SELECTORS
)
}
// --- 설정 저장 로직 ---
LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles(scrapedFolderPath, logMessages); prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) }
LaunchedEffect(apiKey) { prefs.put(PREF_API_KEY, apiKey) }
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) }
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
// ⭐️ [추가] 셀렉터 목록이 변경될 때마다 자동으로 Preferences에 저장
LaunchedEffect(articleSelectors) {
prefs.put(PREF_ARTICLE_SELECTORS, articleSelectors.joinToString("\n"))
}
// --- selectedFiles 변경 감지 로직 ---
LaunchedEffect(selectedFiles) {
coroutineScope.launch(Dispatchers.IO) {
val allImages = mutableSetOf<String>()
val allPreSelectedImages = mutableSetOf<String>()
selectedFiles.forEach { file ->
try {
val data = jsonParser.decodeFromString<ScrapedData>(file.readText())
allImages.addAll(data.allImageUrls)
allPreSelectedImages.addAll(data.selectedImageUrls)
} catch (e: Exception) {
logMessage(logMessages, "⚠️ '${file.name}' 파일 파싱 오류: ${e.message}")
}
}
combinedImagesFromSelectedFiles = allImages.toList().sorted()
currentSelectedImages = allPreSelectedImages
}
}
// --- 다이얼로그 관리 ---
if (isImageUploadDialogVisible) { FileDialog { selected -> isImageUploadDialogVisible = false; if (selected.isNotEmpty()) { uploadedImageFiles = uploadedImageFiles + selected; logMessage(logMessages, Strings.logImageFilesAdded(selected.size)) } } }
if (isReceiptDialogVisible) { FileDialog { selected -> isReceiptDialogVisible = false; if (selected.isNotEmpty()) { receiptFiles = receiptFiles + selected; logMessage(logMessages, Strings.logReceiptsAdded(selected.size)) } } }
MaterialTheme {
Column(modifier = Modifier.fillMaxSize()) {
// --- 상단 설정 UI ---
Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it }); Text(Strings.LABEL_BROWSER_VISIBLE, modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f)); Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it }); Text(Strings.LABEL_BROWSER_SESSION, modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f)) }
Spacer(Modifier.height(8.dp)); OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text(Strings.LABEL_SCRAP_FOLDER_PATH) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Spacer(Modifier.height(4.dp)); OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text(Strings.PLACEHOLDER_API_KEY) }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.height(4.dp))
Row(Modifier.fillMaxWidth()) { OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text(Strings.LABEL_BLOG_WORKSPACE_SLUG) }, modifier = Modifier.weight(1f), singleLine = true); Spacer(Modifier.width(4.dp)); OutlinedTextField(value = receiptWorkspaceSlug, onValueChange = { receiptWorkspaceSlug = it }, label = { Text(Strings.LABEL_RECEIPT_WORKSPACE_SLUG) }, modifier = Modifier.weight(1f), singleLine = true) }
Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text(Strings.LABEL_MODEL_NAME) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
}
Divider()
// --- 탭 ---
TabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } }
// --- 탭별 컨텐츠 ---
when (tabIndex) {
0 -> ScrapBasedPostTab(
isLoading, keywords, searchResults, scrapedFiles, selectedFiles, viewedFileContent,
combinedImagesFromSelectedFiles,
currentSelectedImages, userPrompt, imageLoader, manualKeyword, userScrapComment, userMainTopic,
onManualKeywordChange = { manualKeyword = it }, onUserPromptChange = { userPrompt = it }, onUserScrapCommentChange = { userScrapComment = it }, onUserMainTopicChange = { userMainTopic = it },
onFetchTrends = { coroutineScope.launch(Dispatchers.IO) { isLoading = true; keywords = fetchGoogleTrends(logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
onKeywordSelect = { keyword -> selectedKeyword = keyword; manualKeyword = keyword; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
// ⭐️ [수정] scrapeArticleByUrl 호출 시 articleSelectors 상태 변수 전달
onSearchResultSelect = { result -> coroutineScope.launch(Dispatchers.IO) { isLoading = true; scrapeArticleByUrl(result.url, articleSelectors, logMessages, isBrowserVisible, keepBrowserSession)?.let { data -> val finalKeyword = manualKeyword.takeIf { it.isNotBlank() } ?: "scraped"; saveDataToJsonFile(finalKeyword, data, scrapedFolderPath,logMessages); scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages) }; isLoading = false } },
onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages) } },
onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file },
onFileView = { file ->
coroutineScope.launch(Dispatchers.IO) {
try {
val data = jsonParser.decodeFromString<ScrapedData>(file.readText())
currentlyOpenFile = file
viewedFileContent = data.content
} catch (e: Exception) {
logMessage(logMessages, Strings.logReadFileError(e.message))
}
}
},
onImageSelect = { imageUrl -> currentSelectedImages = if (imageUrl in currentSelectedImages) currentSelectedImages - imageUrl else currentSelectedImages + imageUrl },
onSaveChanges = {
coroutineScope.launch(Dispatchers.IO) {
if (selectedFiles.isEmpty()) {
logMessage(logMessages, Strings.LOG_WARN_NO_FILES_TO_SAVE)
return@launch
}
selectedFiles.forEach { file ->
try {
val originalData = jsonParser.decodeFromString<ScrapedData>(file.readText())
val imagesToSaveForThisFile = currentSelectedImages.intersect(originalData.allImageUrls.toSet())
val updatedData = originalData.copy(selectedImageUrls = imagesToSaveForThisFile.toList())
file.writeText(jsonParser.encodeToString(updatedData))
} catch (e: Exception) {
logMessage(logMessages, Strings.logSaveFileError(file.name, e.message))
}
}
logMessage(logMessages, Strings.logImageSelectionSavedToFiles(selectedFiles.size))
}
},
onGeneratePost = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@ScrapBasedPostTab }
if (selectedFiles.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_NO_FILES_SELECTED); return@ScrapBasedPostTab }
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
try {
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
val allImages = currentSelectedImages.toList()
val uploadedDocIds = uploadFiles(selectedFiles.toList(), apiKey, workspaceSlug, logMessages)
if (uploadedDocIds.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_UPLOAD_FAILED); return@launch }
val resultText = generateBlogPost(
scrapedDataList = selectedDataList,
userOwnContent = "",
allSelectedImages = allImages,
userDirection = userPrompt,
userScrapComment = userScrapComment,
userMainTopic = userMainTopic,
apiKey = apiKey,
workspaceSlug = workspaceSlug,
generatePromptPrefix = generatePromptPrefix,
generatePromptInstructions = generatePromptInstructions,
logs = logMessages
)
val footer = buildString {
appendLine(Strings.Footer.SEPARATOR)
if (selectedDataList.isNotEmpty()) {
appendLine(Strings.Footer.ORIGINAL_SOURCE_TITLE)
selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") }
}
if (allImages.isNotEmpty()) {
appendLine(Strings.Footer.USED_IMAGES_TITLE)
allImages.forEach { appendLine(" - $it") }
}
appendLine(Strings.Footer.PROCESS_TITLE)
val contextSummary = mutableListOf<String>()
if (userMainTopic.isNotBlank()) contextSummary.add(Strings.Footer.contextTopic(userMainTopic))
if (userScrapComment.isNotBlank()) contextSummary.add(Strings.Footer.contextComment(userScrapComment))
if (userPrompt.isNotBlank()) contextSummary.add(Strings.Footer.contextStyle(userPrompt))
val contextText = if (contextSummary.isNotEmpty()) "${contextSummary.joinToString(", ")} " else ""
append(Strings.Footer.scrapBased(contextText, modelName))
}
val contextBuilder = StringBuilder()
contextBuilder.appendLine("\n\n---")
contextBuilder.appendLine("[Generation Context & Prompts]")
contextBuilder.appendLine("- Model: $modelName")
contextBuilder.appendLine("- Generation Type: Scrap-based Posting")
contextBuilder.appendLine("\n[Inputs]")
if (userMainTopic.isNotBlank()) contextBuilder.appendLine("- Main Topic: $userMainTopic")
if (userScrapComment.isNotBlank()) contextBuilder.appendLine("- User Comment: $userScrapComment")
contextBuilder.appendLine("- Style Request: $userPrompt")
contextBuilder.appendLine("- Source Files: ${selectedFiles.joinToString { it.name }}")
contextBuilder.appendLine("\n[Underlying Prompt]")
contextBuilder.appendLine("--- Prefix ---")
contextBuilder.appendLine(generatePromptPrefix)
contextBuilder.appendLine("\n--- Instructions ---")
generatePromptInstructions.forEach { contextBuilder.appendLine("- $it") }
generationContextForClipboard = contextBuilder.toString()
blogPostResult = resultText
blogPostFooter = footer
tabIndex = tabs.indexOf("블로그 결과")
cleanupWorkspace(uploadedDocIds, apiKey, logMessages)
} finally { isLoading = false }
}
}
)
1 -> DirectPostTab(
isLoading = isLoading,
userOwnContent = userOwnContent,
onUserOwnContentChange = { userOwnContent = it },
uploadedImageFiles = uploadedImageFiles,
imageLoader = imageLoader,
userPrompt = userPrompt,
onUserPromptChange = { userPrompt = it },
onUploadImage = { isImageUploadDialogVisible = true },
onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove },
onGeneratePost = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@DirectPostTab }
if (userOwnContent.isBlank() && uploadedImageFiles.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_DIRECT_POST_EMPTY); return@DirectPostTab }
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
try {
val allImages = uploadedImageFiles.map { it.toURI().toString() }
val resultText = generateBlogPost(
scrapedDataList = emptyList(),
userOwnContent = userOwnContent,
allSelectedImages = allImages,
userDirection = userPrompt,
userScrapComment = "",
userMainTopic = "",
apiKey = apiKey,
workspaceSlug = workspaceSlug,
generatePromptPrefix = generatePromptPrefix,
generatePromptInstructions = generatePromptInstructions,
logs = logMessages
)
val footer = buildString {
appendLine(Strings.Footer.SEPARATOR)
if (allImages.isNotEmpty()) {
appendLine(Strings.Footer.USED_IMAGES_TITLE)
allImages.forEach { appendLine(" - $it") }
}
appendLine(Strings.Footer.PROCESS_TITLE)
val postscript = if (userPrompt.isNotBlank()) Strings.Footer.directPost(userPrompt, modelName)
else Strings.Footer.directPostNoPrompt(modelName)
append(postscript)
}
val contextBuilder = StringBuilder()
contextBuilder.appendLine("\n\n---")
contextBuilder.appendLine("[Generation Context & Prompts]")
contextBuilder.appendLine("- Model: $modelName")
contextBuilder.appendLine("- Generation Type: Direct Posting")
contextBuilder.appendLine("\n[Inputs]")
contextBuilder.appendLine("- User Content Summary: ${userOwnContent.take(100)}...")
contextBuilder.appendLine("- Style Request: $userPrompt")
contextBuilder.appendLine("- Attached Images: ${uploadedImageFiles.joinToString { it.name }}")
contextBuilder.appendLine("\n[Underlying Prompt]")
contextBuilder.appendLine("--- Prefix ---")
contextBuilder.appendLine(generatePromptPrefix)
contextBuilder.appendLine("\n--- Instructions ---")
generatePromptInstructions.forEach { contextBuilder.appendLine("- $it") }
generationContextForClipboard = contextBuilder.toString()
blogPostResult = resultText
blogPostFooter = footer
tabIndex = tabs.indexOf("블로그 결과")
} finally { isLoading = false }
}
}
)
2 -> ReceiptAnalyzerTab(
isLoading, receiptFiles, receiptAnalysisResult, isAnalyzing = analysisJob?.isActive == true,
receiptContextPrompt, imageLoader,
onUploadReceipt = { isReceiptDialogVisible = true },
onRemoveReceipt = { file -> receiptFiles = receiptFiles - file },
onReceiptContextPromptChange = { receiptContextPrompt = it },
onAnalyzeReceipts = {
analysisJob = coroutineScope.launch(Dispatchers.IO) {
try {
isLoading = true
val resultFlow = MutableStateFlow("")
val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } }
analyzeReceipts(receiptFiles, apiKey, receiptWorkspaceSlug, receiptContextPrompt, receiptPromptBase, logMessages, resultFlow)
uiUpdateJob.cancel()
} catch (e: CancellationException) { logMessage(logMessages, Strings.LOG_INFO_ANALYSIS_CANCELLED); receiptAnalysisResult = Strings.ANALYSIS_STOPPED
} catch (e: Exception) { logMessage(logMessages, Strings.logAnalysisError(e.message)); receiptAnalysisResult = Strings.analysisError(e.message)
} finally { isLoading = false; analysisJob = null }
}
},
onCancelAnalysis = { analysisJob?.cancel() }
)
3 -> LogTab(logMessages)
4 -> ResultTab(
result = blogPostResult,
onRequestResultChange = { blogPostResult = it },
revisionRequest = revisionRequest,
onRevisionRequestChange = { revisionRequest = it },
isLoading = isLoading,
onRevise = {
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@ResultTab }
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
try {
val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug, revisePromptPrefix, logMessages)
blogPostResult = revisedText
revisionRequest = ""
} finally {
isLoading = false
}
}
},
onCopyToClipboard = {
val fullContent = blogPostResult + blogPostFooter + generationContextForClipboard
val stringSelection = StringSelection(fullContent)
Toolkit.getDefaultToolkit().systemClipboard.setContents(stringSelection, null)
logMessage(logMessages, Strings.LOG_CLIPBOARD_COPY_SUCCESS)
}
)
5 -> SettingsTab(
generatePromptPrefix = generatePromptPrefix,
onGeneratePromptPrefixChange = { generatePromptPrefix = it },
generatePromptInstructions = generatePromptInstructions,
onGeneratePromptInstructionsChange = { generatePromptInstructions = it },
revisePrompt = revisePromptPrefix,
onRevisePromptChange = { revisePromptPrefix = it },
receiptPrompt = receiptPromptBase,
onReceiptPromptChange = { receiptPromptBase = it },
isLoading = isLoading,
// ⭐️ [추가] SettingsTab에 셀렉터 상태와 핸들러 전달
articleSelectors = articleSelectors,
onArticleSelectorsChange = { articleSelectors = it },
onSave = {
prefs.put(PREF_PROMPT_GENERATE, generatePromptPrefix)
prefs.put(PREF_PROMPT_REVISE, revisePromptPrefix)
prefs.put(PREF_PROMPT_RECEIPT, receiptPromptBase)
prefs.put(PREF_PROMPT_GENERATE_INSTRUCTIONS, generatePromptInstructions.joinToString("\n"))
prefs.put(PREF_ARTICLE_SELECTORS, articleSelectors.joinToString("\n")) // ⭐️ [수정] 저장 버튼 클릭 시에도 저장되도록 명시 (LaunchedEffect와 중복되지만 안전장치)
logMessage(logMessages, "✅ 프롬프트 및 설정이 저장되었습니다.")
},
onReset = {
generatePromptPrefix = Strings.Prompts.GENERATE_POST_PREFIX
revisePromptPrefix = Strings.Prompts.REVISE_POST_PREFIX
receiptPromptBase = Strings.Prompts.RECEIPT_ANALYSIS_BASE
generatePromptInstructions = Strings.Prompts.DEFAULT_GENERATE_POST_INSTRUCTIONS
articleSelectors = Strings.DEFAULT_ARTICLE_SELECTORS // ⭐️ [추가] 초기화 시 셀렉터도 기본값으로 변경
logMessage(logMessages, "ℹ️ 프롬프트 및 설정을 기본값으로 초기화했습니다.")
}
)
}
}
}
}
+523
View File
@@ -0,0 +1,523 @@
// ui/tabs/tabs.kt
package ui.tabs
import androidx.compose.foundation.HorizontalScrollbar
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.ImageLoader
import coil3.compose.rememberAsyncImagePainter
import models.SearchResult
import utils.Strings
import java.io.File
@Composable
fun ScrapBasedPostTab(
isLoading: Boolean, keywords: List<String>, searchResults: List<SearchResult>, scrapedFiles: List<File>, selectedFiles: Set<File>,
viewedFileContent: String,
combinedImagesFromSelectedFiles: List<String>,
currentSelectedImages: Set<String>,
userPrompt: String, imageLoader: ImageLoader, manualKeyword: String, userScrapComment: String, userMainTopic: String,
onManualKeywordChange: (String) -> Unit, onUserPromptChange: (String) -> Unit, onUserScrapCommentChange: (String) -> Unit, onUserMainTopicChange: (String) -> Unit,
onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit,
onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit, onFileView: (File) -> Unit,
onImageSelect: (String) -> Unit, onSaveChanges: () -> Unit, onGeneratePost: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
// 1. 키워드 및 검색
Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) {
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text(Strings.LABEL_KEYWORD_INPUT) }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading)
Spacer(Modifier.width(4.dp))
Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text(Strings.BUTTON_SEARCH) }
}
Spacer(Modifier.height(8.dp))
Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text(Strings.BUTTON_FETCH_TRENDS) }
Divider(modifier = Modifier.padding(vertical = 8.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) }
}
}
// 2. 검색 결과 및 스크랩
Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(4.dp)) {
Text(Strings.TITLE_SEARCH_RESULTS, style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(searchResults) { result ->
Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) {
Text(result.title, style = MaterialTheme.typography.subtitle1, color = MaterialTheme.colors.primary)
Text(result.url, style = MaterialTheme.typography.caption, maxLines = 1)
}
}
}
}
// 3. 파일 관리 및 생성
Column(modifier = Modifier.weight(3f).padding(horizontal = 4.dp).verticalScroll(rememberScrollState())) {
// 저장된 파일
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(Strings.TITLE_SAVED_FILES, style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
Button(onClick = onRefreshFiles, enabled = !isLoading) { Text(Strings.BUTTON_REFRESH) }
}
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(Strings.TITLE_FILE_CONTENT, 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(Strings.TITLE_SELECT_IMAGES, style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f))
Button(onClick = onSaveChanges, enabled = !isLoading && combinedImagesFromSelectedFiles.isNotEmpty()) { Text(Strings.BUTTON_SAVE_IMAGE_SELECTION) }
}
// ⭐️ [수정] 스크롤바를 표시하기 위해 Box로 감싸고 HorizontalScrollbar 추가
val imageListState = rememberLazyListState()
Box(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) {
LazyRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), // 스크롤바 공간 확보
state = imageListState
) {
items(combinedImagesFromSelectedFiles) { 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)
}
}
}
HorizontalScrollbar(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
adapter = rememberScrollbarAdapter(imageListState)
)
}
}
Spacer(Modifier.height(16.dp))
// 글 생성 제어
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(8.dp)) {
Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6)
OutlinedTextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_PROMPT) })
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = userMainTopic,
onValueChange = onUserMainTopicChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(Strings.LABEL_MAIN_TOPIC) },
singleLine = true
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(value = userScrapComment, onValueChange = onUserScrapCommentChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_COMMENT) })
Spacer(Modifier.height(8.dp))
Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
Text(Strings.buttonGeneratePost(selectedFiles.size))
}
}
}
}
if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
}
}
@Composable
fun DirectPostTab(
isLoading: Boolean,
userOwnContent: String,
onUserOwnContentChange: (String) -> Unit,
uploadedImageFiles: List<File>,
imageLoader: ImageLoader,
userPrompt: String,
onUserPromptChange: (String) -> Unit,
onUploadImage: () -> Unit,
onRemoveUploadedImage: (File) -> Unit,
onGeneratePost: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize().padding(8.dp)) {
// 1. 내용 작성
Column(modifier = Modifier.weight(2f).padding(end = 8.dp)) {
Text(Strings.TITLE_DIRECT_POST, style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
OutlinedTextField(
value = userOwnContent,
onValueChange = onUserOwnContentChange,
modifier = Modifier.fillMaxSize(),
label = { Text(Strings.LABEL_DIRECT_POST_CONTENT) }
)
}
// 2. 이미지 및 생성 제어
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(8.dp)) {
Text(Strings.TITLE_IMAGE_UPLOAD, style = MaterialTheme.typography.h6)
Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_IMAGE) }
Spacer(Modifier.height(8.dp))
// ⭐️ [수정] 스크롤바를 표시하기 위해 Box로 감싸고 HorizontalScrollbar 추가
val imageListState = rememberLazyListState()
Box(modifier = Modifier.fillMaxWidth().height(120.dp)) {
LazyRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
state = imageListState
) {
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
)
}
}
}
HorizontalScrollbar(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
adapter = rememberScrollbarAdapter(imageListState)
)
}
Spacer(Modifier.height(16.dp))
Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6)
OutlinedTextField(
value = userPrompt,
onValueChange = onUserPromptChange,
modifier = Modifier.fillMaxWidth().height(150.dp),
label = { Text(Strings.LABEL_USER_PROMPT) }
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onGeneratePost,
enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(),
modifier = Modifier.fillMaxWidth()
) { Text(Strings.BUTTON_GENERATE_POST_DIRECT) }
}
}
if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
}
}
@Composable
fun ReceiptAnalyzerTab(
isLoading: Boolean,
receiptFiles: List<File>,
receiptAnalysisResult: String,
isAnalyzing: Boolean,
receiptContextPrompt: String,
imageLoader: ImageLoader,
onUploadReceipt: () -> Unit,
onRemoveReceipt: (File) -> Unit,
onReceiptContextPromptChange: (String) -> Unit,
onAnalyzeReceipts: () -> Unit,
onCancelAnalysis: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(Strings.TITLE_RECEIPT_ANALYZER, style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 8.dp))
Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_RECEIPT) }
Spacer(Modifier.height(8.dp))
// ⭐️ [수정] 스크롤바를 표시하기 위해 Box로 감싸고 HorizontalScrollbar 추가
val imageListState = rememberLazyListState()
Box(modifier = Modifier.fillMaxWidth().height(120.dp)) {
LazyRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
state = imageListState
) {
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
)
}
}
}
HorizontalScrollbar(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
adapter = rememberScrollbarAdapter(imageListState)
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = receiptContextPrompt,
onValueChange = onReceiptContextPromptChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(Strings.PLACEHOLDER_RECEIPT_CONTEXT) },
label = { Text(Strings.LABEL_RECEIPT_CONTEXT) }
)
Spacer(Modifier.height(16.dp))
if (isAnalyzing) {
Button(onClick = onCancelAnalysis, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(Strings.BUTTON_CANCEL_ANALYSIS)
}
}
} else {
Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
Text(Strings.buttonStartAnalysis(receiptFiles.size))
}
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = receiptAnalysisResult,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxSize(),
label = { Text(Strings.LABEL_ANALYSIS_RESULT) }
)
}
if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
}
}
@Composable
fun LogTab(logs: List<String>) {
TextField(value = logs.joinToString("\n"), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize().padding(8.dp))
}
@Composable
fun ResultTab(
result: String,
onRequestResultChange: (String) -> Unit,
revisionRequest: String,
onRevisionRequestChange: (String) -> Unit,
isLoading: Boolean,
onRevise: () -> Unit,
onCopyToClipboard: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = result,
onValueChange = onRequestResultChange,
modifier = Modifier.weight(1f).fillMaxWidth(),
label = { Text(Strings.LABEL_BLOG_RESULT) }
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = revisionRequest,
onValueChange = onRevisionRequestChange,
modifier = Modifier.fillMaxWidth().height(100.dp),
label = { Text(Strings.LABEL_REVISION_REQUEST) },
placeholder = { Text(Strings.PLACEHOLDER_REVISION_REQUEST) },
enabled = !isLoading
)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onRevise,
enabled = !isLoading && revisionRequest.isNotBlank(),
modifier = Modifier.weight(1f)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp)
} else {
Text(Strings.BUTTON_REVISE_POST)
}
}
Spacer(Modifier.width(8.dp))
Button(
onClick = onCopyToClipboard,
enabled = !isLoading && result.isNotBlank(),
modifier = Modifier.weight(1f)
) {
Text(Strings.BUTTON_COPY_TO_CLIPBOARD)
}
}
}
}
}
@Composable
fun SettingsTab(
generatePromptPrefix: String,
onGeneratePromptPrefixChange: (String) -> Unit,
generatePromptInstructions: List<String>,
onGeneratePromptInstructionsChange: (List<String>) -> Unit,
revisePrompt: String,
onRevisePromptChange: (String) -> Unit,
receiptPrompt: String,
onReceiptPromptChange: (String) -> Unit,
articleSelectors: List<String>,
onArticleSelectorsChange: (List<String>) -> Unit,
onSave: () -> Unit,
onReset: () -> Unit,
isLoading: Boolean
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
Text("프롬프트 및 설정", style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 16.dp))
// --- 블로그 글 생성 프롬프트 섹션 ---
Text("블로그 글 생성 프롬프트", style = MaterialTheme.typography.h6)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = generatePromptPrefix,
onValueChange = onGeneratePromptPrefixChange,
modifier = Modifier.fillMaxWidth().height(150.dp),
label = { Text("기본 역할 (Prefix)") },
enabled = !isLoading
)
Spacer(Modifier.height(8.dp))
Text("요청사항 목록", style = MaterialTheme.typography.subtitle1)
// LazyColumn은 Column 내에서 높이가 지정되어야 하므로 Box로 감싸서 제한
Box(modifier = Modifier.heightIn(max = 250.dp)) {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
itemsIndexed(generatePromptInstructions) { index, instruction ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = instruction,
onValueChange = { newText ->
val newList = generatePromptInstructions.toMutableList()
newList[index] = newText
onGeneratePromptInstructionsChange(newList)
},
modifier = Modifier.weight(1f),
singleLine = true,
enabled = !isLoading
)
Spacer(Modifier.width(8.dp))
IconButton(onClick = {
val newList = generatePromptInstructions.toMutableList()
newList.removeAt(index)
onGeneratePromptInstructionsChange(newList)
}, enabled = !isLoading) {
Icon(Icons.Default.Delete, contentDescription = "삭제")
}
}
}
}
}
Button(
onClick = { onGeneratePromptInstructionsChange(generatePromptInstructions + "") },
enabled = !isLoading
) {
Text("요청사항 항목 추가")
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// ⭐️ [추가] 스크래핑 CSS 셀렉터 설정 UI
Text("아티클 스크래핑 CSS 셀렉터", style = MaterialTheme.typography.h6)
Text(
"스크랩 시 본문을 찾기 위해 사용되는 CSS 셀렉터 목록입니다. 우선순위가 높은 것을 위로 배치하세요.",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 8.dp)
)
Box(modifier = Modifier.heightIn(max = 250.dp)) {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
itemsIndexed(articleSelectors) { index, selector ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = selector,
onValueChange = { newText ->
val newList = articleSelectors.toMutableList()
newList[index] = newText
onArticleSelectorsChange(newList)
},
modifier = Modifier.weight(1f),
singleLine = true,
enabled = !isLoading
)
Spacer(Modifier.width(8.dp))
IconButton(onClick = {
val newList = articleSelectors.toMutableList()
newList.removeAt(index)
onArticleSelectorsChange(newList)
}, enabled = !isLoading) {
Icon(Icons.Default.Delete, contentDescription = "셀렉터 삭제")
}
}
}
}
}
Button(
onClick = { onArticleSelectorsChange(articleSelectors + "") },
enabled = !isLoading
) {
Text("셀렉터 항목 추가")
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// --- 글 수정 및 영수증 분석 프롬프트 ---
OutlinedTextField(
value = revisePrompt,
onValueChange = onRevisePromptChange,
modifier = Modifier.fillMaxWidth().height(150.dp),
label = { Text("블로그 글 수정 프롬프트") },
enabled = !isLoading
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = receiptPrompt,
onValueChange = onReceiptPromptChange,
modifier = Modifier.fillMaxWidth().height(150.dp),
label = { Text("영수증 분석 프롬프트") },
enabled = !isLoading
)
Spacer(Modifier.height(24.dp))
// --- 저장 및 초기화 버튼 ---
Row(modifier = Modifier.fillMaxWidth()) {
Button(onClick = onSave, enabled = !isLoading, modifier = Modifier.weight(1f)) {
Text("설정 저장하기")
}
Spacer(Modifier.width(16.dp))
Button(
onClick = onReset,
enabled = !isLoading,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary)
) {
Text("기본값으로 초기화")
}
}
}
if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
}
}
+23
View File
@@ -0,0 +1,23 @@
// ui/widgets/FileDialog.kt
package ui.widgets
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import utils.Strings
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@Composable
fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List<File>) -> Unit) {
val fileDialog = remember {
FileDialog(parent, Strings.FILE_DIALOG_TITLE, FileDialog.LOAD).apply {
isMultipleMode = true
}
}
LaunchedEffect(Unit) {
fileDialog.isVisible = true
onCloseRequest(fileDialog.files.toList())
}
}
+304
View File
@@ -0,0 +1,304 @@
// utils/Global.kt
package utils
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import models.ScrapedData
import org.openqa.selenium.WebDriverException
import org.openqa.selenium.chrome.ChromeDriver
import org.openqa.selenium.chrome.ChromeOptions
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.prefs.Preferences
object Global {
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true }) }
install(HttpTimeout) { requestTimeoutMillis = 900000 }
}
val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = true }
val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
const val PREF_FOLDER_PATH = "folder_path"
const val PREF_API_KEY = "api_key"
const val PREF_WORKSPACE_SLUG = "workspace_slug"
const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
const val PREF_MODEL_NAME = "model_name"
// ⭐️ [추가] 프롬프트 저장을 위한 키
const val PREF_PROMPT_GENERATE = "prompt_generate"
const val PREF_PROMPT_REVISE = "prompt_revise"
const val PREF_PROMPT_RECEIPT = "prompt_receipt"
const val PREF_PROMPT_GENERATE_INSTRUCTIONS = "prompt_generate_instructions"
// ⭐️ [추가] 스크래핑 셀렉터 저장을 위한 키
const val PREF_ARTICLE_SELECTORS = "article_selectors"
}
fun logMessage(logs: MutableList<String>, message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
logs.add(0, "$timestamp: $message")
}
object FileManager {
fun saveDataToJsonFile(keyword: String, data: ScrapedData, folderPath: String, logs: MutableList<String>): File {
val directory = File(folderPath).also { if (!it.exists()) it.mkdirs() }
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
val file = File(directory, fileName)
file.writeText(Global.jsonParser.encodeToString(data))
logMessage(logs, Strings.logSavingScrapData(file.path))
return file
}
fun loadScrapedJsonFiles(folderPath: String, logs: MutableList<String>): List<File> {
logMessage(logs, Strings.logLoadingFiles(folderPath))
return File(folderPath).listFiles { _, name -> name.endsWith(".json") }
?.sortedByDescending { it.lastModified() }
.orEmpty()
.also { logMessage(logs, Strings.logLoadedFiles(it.size)) }
}
}
object BrowserManager {
private var driver: ChromeDriver? = null
fun getChromeDriver(options: ChromeOptions): ChromeDriver {
try {
driver?.title // Check if the driver is still active
} catch (e: WebDriverException) {
driver = null // Driver is dead, so nullify it
}
if (driver == null) {
driver = ChromeDriver(options)
}
return driver!!
}
fun quitChromeDriver() {
driver?.quit()
driver = null
}
}
/**
* 애플리케이션 전체에서 사용되는 하드코딩된 문자열을 관리하는 객체입니다.
*/
object Strings {
// --- 기본 정보 ---
const val APP_TITLE = "자동 블로그 포스팅 도우미 v1.0"
// --- 기본값 ---
const val DEFAULT_SCRAPED_FOLDER = "scraped_articles"
const val DEFAULT_WORKSPACE_SLUG = "my-workspace"
const val DEFAULT_RECEIPT_WORKSPACE_SLUG = "receipts"
const val DEFAULT_MODEL_NAME = "Llama-3.1-8B-Vision"
const val DEFAULT_USER_PROMPT = "개인 기록용으로 가볍게 남기는 스타일로 작성해줘."
const val DEFAULT_USER_OWN_CONTENT = "예시: 강릉으로 1박 2일 여행을 다녀왔습니다."
// ⭐️ [추가] 스크래핑 셀렉터 기본값
val DEFAULT_ARTICLE_SELECTORS = listOf(
"main",
"#post-area",
"#postListBody",
"#app",
".app",
"article",
".article-body",
"#article_body",
".news-article-body-view"
)
// --- UI 텍스트: 공통 ---
val TABS = listOf("스크랩 기반 포스팅", "직접 포스팅", "영수증 분석기", "통신 로그", "블로그 결과")
const val BUTTON_REFRESH = "새로고침"
const val LABEL_LLM_REQUEST = "LLM 요청사항"
const val LABEL_USER_PROMPT = "글 스타일, 톤앤매너 등을 지시하세요."
const val PLACEHOLDER_API_KEY = "AnythingLLM API Key"
// --- UI 텍스트: App.kt (환경설정) ---
const val LABEL_BROWSER_VISIBLE = "브라우저 화면 보기"
const val LABEL_BROWSER_SESSION = "브라우저 세션 유지"
const val LABEL_SCRAP_FOLDER_PATH = "스크랩 저장 폴더 경로"
const val LABEL_BLOG_WORKSPACE_SLUG = "블로그용 Workspace Slug"
const val LABEL_RECEIPT_WORKSPACE_SLUG = "영수증 처리용 Workspace Slug"
const val LABEL_MODEL_NAME = "사용 중인 LLM 모델 이름"
// --- UI 텍스트: 스크랩 기반 포스팅 탭 ---
const val LABEL_KEYWORD_INPUT = "키워드 직접 입력"
const val BUTTON_SEARCH = "검색"
const val BUTTON_FETCH_TRENDS = "트렌드 가져오기"
const val TITLE_SEARCH_RESULTS = "검색 결과 (클릭하여 스크랩)"
const val TITLE_SAVED_FILES = "저장된 파일"
const val TITLE_FILE_CONTENT = "파일 내용"
const val TITLE_SELECT_IMAGES = "대표 이미지 선택 (다중 가능)"
const val BUTTON_SAVE_IMAGE_SELECTION = "선택 이미지 저장"
const val LABEL_MAIN_TOPIC = "글의 핵심 주제 (예: 2025년 최신 IT 트렌드)"
const val LABEL_USER_COMMENT = "작성자 코멘트 (스크랩한 내용에 대한 당신의 생각)"
fun buttonGeneratePost(count: Int) = "선택한 파일(${count}개)로 글 생성"
// --- UI 텍스트: 직접 포스팅 탭 ---
const val TITLE_DIRECT_POST = "직접 작성 (여행 기록, 정보 공유 등)"
const val LABEL_DIRECT_POST_CONTENT = "블로그에 올릴 내용을 직접 작성하세요."
const val TITLE_IMAGE_UPLOAD = "이미지 업로드"
const val BUTTON_UPLOAD_IMAGE = "내 PC에서 이미지 업로드"
const val BUTTON_GENERATE_POST_DIRECT = "작성한 내용으로 글 생성"
// --- UI 텍스트: 영수증 분석기 탭 ---
const val TITLE_RECEIPT_ANALYZER = "영수증 분석기"
const val BUTTON_UPLOAD_RECEIPT = "영수증 이미지 업로드"
const val LABEL_RECEIPT_CONTEXT = "추가 정보 입력 (예: 부산 출장 경비)"
const val PLACEHOLDER_RECEIPT_CONTEXT = "추가 정보를 입력하면 더 정확하게 분석할 수 있습니다."
const val BUTTON_CANCEL_ANALYSIS = "분석 중단하기"
const val LABEL_ANALYSIS_RESULT = "분석 결과 (내용 복사하여 사용)"
fun buttonStartAnalysis(count: Int) = "선택한 영수증 분석 시작 (${count}개)"
const val ANALYSIS_STOPPED = "분석이 중단되었습니다."
fun analysisError(message: String?) = "오류 발생: $message"
// --- UI 텍스트: 결과 탭 ---
const val LABEL_BLOG_RESULT = "블로그 글 결과 (LLM 생성 본문)"
const val LABEL_REVISION_REQUEST = "추가 요청사항"
const val PLACEHOLDER_REVISION_REQUEST = "예: 문체를 좀 더 전문적으로 바꿔줘. 1번 항목을 더 자세히 설명해줘."
const val BUTTON_REVISE_POST = "LLM으로 글 보완하기"
const val BUTTON_COPY_TO_CLIPBOARD = "전체 내용 클립보드에 복사"
// --- UI 텍스트: 플레이스홀더 ---
const val PLACEHOLDER_BLOG_POST = "LLM으로부터 생성된 블로그 글이 여기에 표시됩니다."
const val PLACEHOLDER_FILE_CONTENT = "파일을 선택하면 내용이 여기에 표시됩니다."
const val PLACEHOLDER_RECEIPT_ANALYSIS = "영수증을 업로드하고 분석을 시작하세요."
// --- UI 텍스트: FileDialog ---
const val FILE_DIALOG_TITLE = "파일 선택"
// --- 로그 메시지 ---
fun logImageFilesAdded(count: Int) = "✅ 개인 이미지 파일 ${count}개 추가됨."
fun logReceiptsAdded(count: Int) = "🧾 영수증 이미지 ${count}개 추가됨."
fun logReadFileError(message: String?) = "❌ 파일 읽기 오류: $message"
fun logSaveFileError(fileName: String, message: String?) = "❌ '$fileName' 저장 중 오류: $message"
fun logImageSelectionSaved(fileName: String) = "✅ '${fileName}'의 선택 이미지 변경사항 저장 완료."
const val LOG_WARN_NO_FILES_TO_SAVE = "⚠️ 저장할 파일을 선택해주세요."
const val LOG_WARN_MISSING_API_CONFIG = "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."
const val LOG_WARN_NO_FILES_SELECTED = "⚠️ 글을 생성할 스크랩 파일을 1개 이상 선택해주세요."
const val LOG_WARN_UPLOAD_FAILED = "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다."
const val LOG_WARN_DIRECT_POST_EMPTY = "⚠️ 직접 포스팅을 하려면 내용과 이미지가 모두 필요합니다."
const val LOG_INFO_ANALYSIS_CANCELLED = "ℹ️ 영수증 분석이 사용자에 의해 중단되었습니다."
fun logAnalysisError(message: String?) = "❌ 영수증 분석 중 심각한 오류 발생: $message"
const val LOG_CLIPBOARD_COPY_SUCCESS = "✅ 블로그 전체 내용(꼬리말 및 생성 정보 포함)이 클립보드에 복사되었습니다."
fun logLoadingFiles(path: String) = "'$path'에서 파일 목록 로딩..."
fun logLoadedFiles(count: Int) = "✅ 파일 ${count}개 로딩 완료."
fun logSavingScrapData(path: String) = "✅ '${path}'에 스크랩 데이터 저장 완료."
const val LOG_TRENDS_START = "Google Trends 페이지 스크랩 시작..."
fun logTrendsSuccess(count: Int) = "✅ Google Trends 키워드 ${count}개 스크랩 완료."
fun logTrendsError(message: String?) = "❌ Google Trends 스크랩 오류: $message"
fun logSearchStart(keyword: String) = "'$keyword' 키워드로 Google 검색 시작..."
fun logSearchSuccess(keyword: String, count: Int) = "✅ '$keyword' 검색 결과 ${count}개 수집 완료."
fun logSearchError(message: String?) = "❌ Google 검색 중 오류: $message"
fun logScrapeUrlStart(url: String) = "URL 스크랩 시작: $url"
const val LOG_WARN_ARTICLE_BODY_NOT_FOUND = "⚠️ 기사 본문을 찾을 수 없습니다."
fun logScrapeUrlSuccess(imageCount: Int) = "✅ URL 스크랩 완료. (이미지 ${imageCount}개 발견)"
fun logScrapeUrlError(message: String?) = "❌ URL 스크랩 중 오류: $message"
fun logLlmUploadStarting(count: Int) = "LLM에 파일 ${count}개 순차 업로드 시작..."
fun logLlmUploadingFile(name: String) = " - '${name}' 업로드 중..."
fun logLlmUploadSuccess(name: String, id: String) = " ✅ '${name}' 업로드 성공 (ID: $id)."
const val LOG_LLM_UPLOAD_NO_DOC_INFO = "업로드 응답에 문서 정보가 없습니다."
fun logLlmUploadResponseError(name: String, status: Any, body: String) = " ❌ '${name}' 업로드 실패: $status - $body"
fun logLlmUploadException(name: String, message: String?) = " ❌ '${name}' 업로드 중 오류: $message"
fun logLlmUploadFinished(total: Int, success: Int) = "${total}개 중 ${success}개 파일 업로드 완료."
const val LOG_LLM_POST_GENERATION_START = "LLM 블로그 글 생성 요청..."
const val LOG_LLM_POST_GENERATION_SUCCESS = "✅ LLM 블로그 글 생성 완료."
fun logLlmApiError(message: String?) = "❌ LLM 호출 중 오류: $message"
const val LOG_LLM_REVISION_START = "LLM 블로그 글 수정 요청..."
const val LOG_LLM_REVISION_EMPTY = "⚠️ 수정 요청사항이 비어있어 수정을 중단합니다."
const val LOG_LLM_REVISION_SUCCESS = "✅ LLM 블로그 글 수정 완료."
fun logLlmCleanupStart(count: Int) = "LLM 워크스페이스 정리 시작 (파일 ${count}개 삭제)..."
const val LOG_LLM_CLEANUP_SUCCESS = "✅ LLM 워크스페이스 정리 완료."
fun logLlmCleanupError(status: Any, body: String) = "❌ LLM 워크스페이스 정리 실패: $status - $body"
fun logLlmCleanupException(message: String?) = "❌ LLM 워크스페이스 정리 중 오류: $message"
fun logReceiptAnalysisMissing(missing: String) = "⚠️ 영수증 분석을 시작할 수 없습니다. ($missing 누락)"
const val LOG_RECEIPT_ANALYSIS_START = "Starting receipt analysis with stream-chat mode..."
const val LOG_RECEIPT_STREAM_FINALIZED = "✅ Stream finalized."
fun logReceiptStreamMetrics(tokens: Int, duration: Double?) = " - Metrics: total_tokens=${tokens}, duration=${duration}s"
fun logReceiptStreamAborted(reason: String?) = "⚠️ Stream aborted by server. Reason: $reason"
fun logReceiptChunkParsingError(message: String?, chunk: String) = "❌ Chunk parsing error: $message | Chunk: $chunk"
const val LOG_RECEIPT_STREAM_FINISHED = "Receipt analysis stream processing finished."
fun logReceiptApiError(status: Any, body: String) = "❌ Error during receipt analysis: $status - $body"
fun logReceiptException(message: String?) = "❌ Exception in receipt analysis: $message"
fun logImageSelectionSavedToFiles(count: Int) = "✅ 선택된 ${count}개 파일에 이미지 선택 변경사항 저장 완료."
// --- API 프롬프트 ---
object Prompts {
// ⭐️ [수정] 프롬프트의 기본 지시사항(역할)을 상수로 분리
const val GENERATE_POST_PREFIX = "당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요."
const val REVISE_POST_PREFIX = "당신은 주어진 글을 사용자의 요청에 맞게 수정하는 전문 편집자입니다. 아래의 원본 글을 수정 요청사항에 따라 개선해주세요. 원본의 주제와 주요 내용은 유지하되, 요청을 충실히 반영하여 더 나은 글로 만들어주세요. 마크다운 형식은 유지해주세요."
const val RECEIPT_ANALYSIS_BASE = "첨부된 영수증 이미지들을 분석해줘."
// ⭐️ [수정] basePrompt를 인자로 받도록 변경
fun receiptAnalysisWithContext(basePrompt: String, context: String) = """$basePrompt --- 추가 정보 --- $context --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘."""
val DEFAULT_GENERATE_POST_INSTRUCTIONS = listOf(
"제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.",
"글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요.",
"가능한 자연스럽고 유머러스하게 글을 작성해주세요.",
"주제에 벗어나지 않고 해당 주제에 포커스를 맞춰서 글을 작성해주세요."
)
// ⭐️ [수정] generateBlogPost 함수가 요청사항 리스트를 동적으로 조립하도록 변경
fun generateBlogPost(
basePrompt: String,
userDirection: String,
instructions: List<String>, // List<String>을 인자로 받음
contentSourcePromptPart: String,
commentPromptPart: String,
mainTopicPromptPart: String,
imagePromptPart: String
): String {
// 동적 요청사항과 사용자 정의 요청사항을 합쳐서 최종 리스트 생성
val finalInstructions = mutableListOf<String>()
finalInstructions.add("사용자 요청: \"$userDirection\"")
finalInstructions.addAll(instructions)
if (imagePromptPart.isNotBlank()) {
// 이미지 삽입 요청은 항상 마지막 부분에 추가되도록 조정
finalInstructions.add(imagePromptPart.replaceFirst("4. ", ""))
}
// 최종 리스트를 번호와 함께 문자열로 변환
val instructionsText = finalInstructions.mapIndexed { index, text -> "${index + 1}. $text" }.joinToString("\n")
return """
$basePrompt
$contentSourcePromptPart
$commentPromptPart
$mainTopicPromptPart
--- 요청사항 ---
$instructionsText
""".trimIndent()
}
}
// --- 포스트 꼬리말 ---
object Footer {
const val SEPARATOR = "\n\n---"
const val ORIGINAL_SOURCE_TITLE = "- 원문 출처:"
const val USED_IMAGES_TITLE = "\n- 사용된 이미지:"
const val PROCESS_TITLE = "\n\n[이 글의 작성 과정]"
fun scrapBased(contextSummary: String, modelName: String) = "이 포스팅은 ${contextSummary}등의 정보를 바탕으로, 여러 참고 자료를 종합하여 ${modelName} AI 모델이 초안을 생성했습니다. 이후 작성자의 검토를 거쳐 수정 및 발행되었습니다."
fun directPost(userPrompt: String, modelName: String) = "이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, '${userPrompt.take(30)}...' 스타일로 문체와 구성을 다듬도록 요청하여, ${modelName} AI 모델의 도움을 받아 완성되었습니다."
fun directPostNoPrompt(modelName: String) = "이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ${modelName} AI 모델의 도움을 받아 완성되었습니다."
fun contextTopic(topic: String) = "주제: '${topic}'"
fun contextComment(comment: String) = "작성자 코멘트: '${comment.take(30)}...'"
fun contextStyle(prompt: String) = "요청 스타일: '${prompt.take(30)}...'"
}
}