...
This commit is contained in:
parent
6d66a0147c
commit
92aa1a998e
@ -1,62 +1,12 @@
|
|||||||
import androidx.compose.foundation.Image
|
// Main.kt
|
||||||
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.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.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
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
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.compose.rememberAsyncImagePainter
|
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import core.BrowserManager.quitChromeDriver
|
|
||||||
import io.ktor.client.*
|
|
||||||
import io.ktor.client.call.*
|
|
||||||
import io.ktor.client.engine.cio.*
|
|
||||||
import io.ktor.client.plugins.*
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
|
||||||
import io.ktor.client.request.*
|
|
||||||
import io.ktor.client.request.forms.MultiPartFormDataContent
|
|
||||||
import io.ktor.client.request.forms.formData
|
|
||||||
import io.ktor.client.statement.*
|
|
||||||
import io.ktor.http.*
|
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
|
||||||
import io.ktor.utils.io.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.openqa.selenium.By
|
|
||||||
import org.openqa.selenium.WebDriverException
|
|
||||||
import org.openqa.selenium.chrome.ChromeDriver
|
|
||||||
import org.openqa.selenium.chrome.ChromeOptions
|
|
||||||
import ui.App
|
import ui.App
|
||||||
|
import utils.BrowserManager.quitChromeDriver
|
||||||
import utils.Global.httpClient
|
import utils.Global.httpClient
|
||||||
import java.awt.FileDialog
|
import utils.Strings
|
||||||
import java.awt.Frame
|
|
||||||
import java.awt.Toolkit
|
|
||||||
import java.awt.datatransfer.StringSelection
|
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
import java.util.prefs.Preferences
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun main() = application {
|
fun main() = application {
|
||||||
@ -65,7 +15,7 @@ fun main() = application {
|
|||||||
.build()
|
.build()
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() },
|
onCloseRequest = { quitChromeDriver(); httpClient.close(); exitApplication() },
|
||||||
title = "자동 블로그 포스팅 도우미 v14.0 (Contextual Footer)" // ⭐️ [수정] 버전명 변경
|
title = Strings.APP_TITLE
|
||||||
) {
|
) {
|
||||||
App(imageLoader)
|
App(imageLoader)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
// core/BrowserManager.kt
|
|
||||||
package core
|
|
||||||
|
|
||||||
import org.openqa.selenium.WebDriverException
|
|
||||||
import org.openqa.selenium.chrome.ChromeDriver
|
|
||||||
import org.openqa.selenium.chrome.ChromeOptions
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
// core/FileManager.kt
|
|
||||||
package core
|
|
||||||
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import models.ScrapedData
|
|
||||||
import utils.Global
|
|
||||||
import utils.logMessage
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
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, "✅ '${file.path}'에 스크랩 데이터 저장 완료.")
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadScrapedJsonFiles(folderPath: String, logs: MutableList<String>): List<File> {
|
|
||||||
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
|
|
||||||
return File(folderPath).listFiles { _, name -> name.endsWith(".json") }
|
|
||||||
?.sortedByDescending { it.lastModified() }
|
|
||||||
.orEmpty()
|
|
||||||
.also { logMessage(logs, "✅ 파일 ${it.size}개 로딩 완료.") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,18 +12,19 @@ import models.*
|
|||||||
import utils.Global
|
import utils.Global
|
||||||
import utils.Global.httpClient
|
import utils.Global.httpClient
|
||||||
import utils.Global.jsonParser
|
import utils.Global.jsonParser
|
||||||
|
import utils.Strings
|
||||||
import utils.logMessage
|
import utils.logMessage
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Base64
|
import java.util.*
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
object LlmApiService {
|
object LlmApiService {
|
||||||
|
|
||||||
suspend fun uploadFiles(files: List<File>, apiKey: String, workspaceSlug: String, logs: MutableList<String>): List<String> {
|
suspend fun uploadFiles(files: List<File>, apiKey: String, workspaceSlug: String, logs: MutableList<String>): List<String> {
|
||||||
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
|
logMessage(logs, Strings.logLlmUploadStarting(files.size))
|
||||||
val uploadedDocIds = mutableListOf<String>()
|
val uploadedDocIds = mutableListOf<String>()
|
||||||
files.forEach { file ->
|
files.forEach { file ->
|
||||||
logMessage(logs, " - '${file.name}' 업로드 중...")
|
logMessage(logs, Strings.logLlmUploadingFile(file.name))
|
||||||
try {
|
try {
|
||||||
val response: HttpResponse = Global.httpClient.post("http://localhost:3001/api/v1/document/upload") {
|
val response: HttpResponse = Global.httpClient.post("http://localhost:3001/api/v1/document/upload") {
|
||||||
header("Authorization", "Bearer $apiKey")
|
header("Authorization", "Bearer $apiKey")
|
||||||
@ -35,11 +36,13 @@ object LlmApiService {
|
|||||||
}
|
}
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
val uploadResponse = response.body<UploadResponse>()
|
val uploadResponse = response.body<UploadResponse>()
|
||||||
uploadResponse.documents.firstOrNull()?.id?.let { uploadedDocIds.add(it); logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $it).") } ?: logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
|
uploadResponse.documents.firstOrNull()?.id?.let {
|
||||||
} else { logMessage(logs, " ❌ '${file.name}' 업로드 실패: ${response.status} - ${response.bodyAsText()}") }
|
uploadedDocIds.add(it); logMessage(logs, Strings.logLlmUploadSuccess(file.name, it))
|
||||||
} catch (e: Exception) { logMessage(logs, " ❌ '${file.name}' 업로드 중 오류: ${e.message}") }
|
} ?: 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, "총 ${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.")
|
logMessage(logs, Strings.logLlmUploadFinished(files.size, uploadedDocIds.size))
|
||||||
return uploadedDocIds
|
return uploadedDocIds
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,9 +55,11 @@ object LlmApiService {
|
|||||||
userMainTopic: String,
|
userMainTopic: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
workspaceSlug: String,
|
workspaceSlug: String,
|
||||||
|
generatePromptPrefix: String,
|
||||||
|
generatePromptInstructions: List<String>, // ⭐️ [추가] 요청사항 리스트 인자
|
||||||
logs: MutableList<String>
|
logs: MutableList<String>
|
||||||
): String {
|
): String {
|
||||||
logMessage(logs, "LLM 블로그 글 생성 요청...")
|
logMessage(logs, Strings.LOG_LLM_POST_GENERATION_START)
|
||||||
|
|
||||||
val contentSourcePromptPart = if (scrapedDataList.isNotEmpty()) {
|
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" }
|
scrapedDataList.mapIndexed { index, data -> "[참고자료 ${index + 1}]\n- 원문 출처: ${data.sourceUrl}\n- 내용 요약: ${data.content.take(500)}...\n" }.joinToString("\n").let { "--- 참고자료 ---\n$it" }
|
||||||
@ -72,29 +77,26 @@ object LlmApiService {
|
|||||||
|
|
||||||
val imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "" }.joinToString("\n")}" else ""
|
val imagePromptPart = if (allSelectedImages.isNotEmpty()) "4. 글의 적절한 위치에 아래 이미지들을 마크다운 형식으로 자연스럽게 삽입해주세요.\n${allSelectedImages.mapIndexed { index, url -> "" }.joinToString("\n")}" else ""
|
||||||
|
|
||||||
val finalPrompt = """
|
// ⭐️ [수정] Strings.Prompts.generateBlogPost 호출 시 인자 추가
|
||||||
당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다. 아래 내용과 요청사항을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요.
|
val finalPrompt = Strings.Prompts.generateBlogPost(
|
||||||
$contentSourcePromptPart
|
basePrompt = generatePromptPrefix,
|
||||||
$commentPromptPart
|
userDirection = userDirection,
|
||||||
$mainTopicPromptPart
|
instructions = generatePromptInstructions,
|
||||||
--- 요청사항 ---
|
contentSourcePromptPart = contentSourcePromptPart,
|
||||||
1. 사용자 요청: "$userDirection"
|
commentPromptPart = commentPromptPart,
|
||||||
2. 제공된 자료를 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.
|
mainTopicPromptPart = mainTopicPromptPart,
|
||||||
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요.
|
imagePromptPart = imagePromptPart
|
||||||
$imagePromptPart
|
)
|
||||||
5. 가능한 자연스럽고 유머러스하게 글을 작성해주세요.
|
|
||||||
6. 주제에 벗어나지 않고 해당 주제에 포커스를 맞춰서 글을 작성해주세요.
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
|
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/workspace/$workspaceSlug/chat") {
|
||||||
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
|
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
|
||||||
}
|
}
|
||||||
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
|
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
|
||||||
logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
|
logMessage(logs, Strings.LOG_LLM_POST_GENERATION_SUCCESS)
|
||||||
return chatResponse.textResponse
|
return chatResponse.textResponse
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logMessage(logs, "❌ LLM 호출 중 오류: ${e.message}"); return "블로그 글 생성 실패: ${e.message}"
|
logMessage(logs, Strings.logLlmApiError(e.message)); return "블로그 글 생성 실패: ${e.message}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,16 +105,18 @@ object LlmApiService {
|
|||||||
revisionRequest: String,
|
revisionRequest: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
workspaceSlug: String,
|
workspaceSlug: String,
|
||||||
|
revisePromptPrefix: String, // ⭐️ [수정] 프롬프트 인자 추가
|
||||||
logs: MutableList<String>
|
logs: MutableList<String>
|
||||||
): String {
|
): String {
|
||||||
logMessage(logs, "LLM 블로그 글 수정 요청...")
|
logMessage(logs, Strings.LOG_LLM_REVISION_START)
|
||||||
if (revisionRequest.isBlank()) {
|
if (revisionRequest.isBlank()) {
|
||||||
logMessage(logs, "⚠️ 수정 요청사항이 비어있어 수정을 중단합니다.")
|
logMessage(logs, Strings.LOG_LLM_REVISION_EMPTY)
|
||||||
return currentPost
|
return currentPost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⭐️ [수정] 인자로 받은 프롬프트를 사용
|
||||||
val finalPrompt = """
|
val finalPrompt = """
|
||||||
당신은 주어진 글을 사용자의 요청에 맞게 수정하는 전문 편집자입니다. 아래의 원본 글을 수정 요청사항에 따라 개선해주세요. 원본의 주제와 주요 내용은 유지하되, 요청을 충실히 반영하여 더 나은 글로 만들어주세요. 마크다운 형식은 유지해주세요.
|
$revisePromptPrefix
|
||||||
|
|
||||||
--- 원본 글 ---
|
--- 원본 글 ---
|
||||||
$currentPost
|
$currentPost
|
||||||
@ -128,23 +132,23 @@ object LlmApiService {
|
|||||||
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
|
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(AnythingLLMChatRequest(message = finalPrompt))
|
||||||
}
|
}
|
||||||
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
|
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(response.bodyAsText())
|
||||||
logMessage(logs, "✅ LLM 블로그 글 수정 완료.")
|
logMessage(logs, Strings.LOG_LLM_REVISION_SUCCESS)
|
||||||
return chatResponse.textResponse
|
return chatResponse.textResponse
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logMessage(logs, "❌ LLM 수정 호출 중 오류: ${e.message}"); return "블로그 글 수정 실패: ${e.message}"
|
logMessage(logs, Strings.logLlmApiError(e.message)); return "블로그 글 수정 실패: ${e.message}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cleanupWorkspace(docIds: List<String>, apiKey: String, logs: MutableList<String>) {
|
suspend fun cleanupWorkspace(docIds: List<String>, apiKey: String, logs: MutableList<String>) {
|
||||||
if (docIds.isEmpty()) return
|
if (docIds.isEmpty()) return
|
||||||
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...")
|
logMessage(logs, Strings.logLlmCleanupStart(docIds.size))
|
||||||
try {
|
try {
|
||||||
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") {
|
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") {
|
||||||
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(DeleteDocumentsRequest(deletes = docIds))
|
header("Authorization", "Bearer $apiKey"); contentType(ContentType.Application.Json); setBody(DeleteDocumentsRequest(deletes = docIds))
|
||||||
}
|
}
|
||||||
if (response.status.isSuccess()) { logMessage(logs, "✅ LLM 워크스페이스 정리 완료.") }
|
if (response.status.isSuccess()) { logMessage(logs, Strings.LOG_LLM_CLEANUP_SUCCESS) }
|
||||||
else { logMessage(logs, "❌ LLM 워크스페이스 정리 실패: ${response.status} - ${response.bodyAsText()}") }
|
else { logMessage(logs, Strings.logLlmCleanupError(response.status, response.bodyAsText())) }
|
||||||
} catch (e: Exception) { logMessage(logs, "❌ LLM 워크스페이스 정리 중 오류: ${e.message}") }
|
} catch (e: Exception) { logMessage(logs, Strings.logLlmCleanupException(e.message)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun analyzeReceipts(
|
suspend fun analyzeReceipts(
|
||||||
@ -152,18 +156,21 @@ object LlmApiService {
|
|||||||
apiKey: String,
|
apiKey: String,
|
||||||
receiptWorkspaceSlug: String,
|
receiptWorkspaceSlug: String,
|
||||||
receiptContext: String,
|
receiptContext: String,
|
||||||
|
receiptPromptBase: String, // ⭐️ [수정] 프롬프트 인자 추가
|
||||||
logs: MutableList<String>,
|
logs: MutableList<String>,
|
||||||
resultState: MutableStateFlow<String>
|
resultState: MutableStateFlow<String>
|
||||||
) {
|
) {
|
||||||
if (apiKey.isBlank() || receiptWorkspaceSlug.isBlank() || files.isEmpty()) {
|
if (apiKey.isBlank() || receiptWorkspaceSlug.isBlank() || files.isEmpty()) {
|
||||||
val missing = listOfNotNull(if (apiKey.isBlank()) "API Key" else null, if (receiptWorkspaceSlug.isBlank()) "영수증 Workspace" else null, if (files.isEmpty()) "영수증 파일" else null).joinToString(); logs.add("⚠️ 영수증 분석을 시작할 수 없습니다. ($missing 누락)"); return
|
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, "Starting receipt analysis with stream-chat mode...")
|
logMessage(logs, Strings.LOG_RECEIPT_ANALYSIS_START)
|
||||||
resultState.value = "영수증 분석 중..."
|
resultState.value = "영수증 분석 중..."
|
||||||
try {
|
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 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 basePrompt = "첨부된 영수증 이미지들을 분석해줘."
|
|
||||||
val finalPrompt = if (receiptContext.isNotBlank()) """$basePrompt --- 추가 정보 --- $receiptContext --- 요청 사항 --- 위 추가 정보를 바탕으로 영수증을 분석하고 비용을 정리해줘. 예를 들어, '부산 출장'이라는 정보가 있다면 각 비용이 부산의 어느 곳에서 발생했는지 주목해서 정리해줘. 총 합계 금액도 요약해줘.""" else basePrompt
|
// ⭐️ [수정] 인자로 받은 프롬프트를 사용
|
||||||
|
val finalPrompt = if (receiptContext.isNotBlank()) Strings.Prompts.receiptAnalysisWithContext(receiptPromptBase, receiptContext) else receiptPromptBase
|
||||||
logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}")
|
logMessage(logs, "LLM Prompt: ${finalPrompt.replace("\n", " ")}")
|
||||||
val requestBody = StreamChatRequest(message = finalPrompt, attachments = attachments, mode = "chat", sessionId = "receipt-analysis-${System.currentTimeMillis()}")
|
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") {
|
httpClient.preparePost("http://localhost:3001/api/v1/workspace/$receiptWorkspaceSlug/stream-chat") {
|
||||||
@ -180,16 +187,16 @@ object LlmApiService {
|
|||||||
try {
|
try {
|
||||||
when (val streamObject = jsonParser.decodeFromString<SealedLLMStreamResponse>(jsonChunk)) {
|
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.TextResponseChunk -> { val textChunk = streamObject.textResponse ?: ""; if (textChunk.isNotBlank() && textChunk != "-") { fullResponseText += textChunk; resultState.value = fullResponseText } }
|
||||||
is SealedLLMStreamResponse.FinalizeResponseStream -> { logMessage(logs, "✅ Stream finalized."); streamObject.metrics?.let { logs.add(" - Metrics: total_tokens=${it.totalTokens}, duration=${it.duration}s") }; break }
|
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, "⚠️ Stream aborted by server. Reason: ${streamObject.textResponse}"); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break }
|
is SealedLLMStreamResponse.AbortResponse -> { logMessage(logs, Strings.logReceiptStreamAborted(streamObject.textResponse)); resultState.value += "\n서버에 의해 중단됨: ${streamObject.textResponse}"; break }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) { logMessage(logs, "❌ Chunk parsing error: ${e.message} | Chunk: $jsonChunk") }
|
} catch (e: Exception) { logMessage(logs, Strings.logReceiptChunkParsingError(e.message, jsonChunk)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logMessage(logs, "Receipt analysis stream processing finished.")
|
logMessage(logs, Strings.LOG_RECEIPT_STREAM_FINISHED)
|
||||||
} else { val errorBody = response.bodyAsText(); logMessage(logs, "❌ Error during receipt analysis: ${response.status} - $errorBody"); resultState.value = "API 오류: ${response.status}" }
|
} else { val errorBody = response.bodyAsText(); logMessage(logs, Strings.logReceiptApiError(response.status, errorBody)); resultState.value = "API 오류: ${response.status}" }
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) { throw e }
|
} catch (e: CancellationException) { throw e }
|
||||||
catch (e: Exception) { logMessage(logs, "❌ Exception in receipt analysis: ${e.message}"); resultState.value = e.message ?: "알 수 없는 오류" }
|
catch (e: Exception) { logMessage(logs, Strings.logReceiptException(e.message)); resultState.value = e.message ?: "알 수 없는 오류" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,31 +1,34 @@
|
|||||||
// core/ScrapingService.kt
|
// core/ScrapingService.kt
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import models.SearchResult
|
|
||||||
import models.ScrapedData
|
import models.ScrapedData
|
||||||
|
import models.SearchResult
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.openqa.selenium.By
|
import org.openqa.selenium.By
|
||||||
import org.openqa.selenium.chrome.ChromeOptions
|
import org.openqa.selenium.chrome.ChromeOptions
|
||||||
|
import utils.BrowserManager
|
||||||
|
import utils.Strings
|
||||||
import utils.logMessage
|
import utils.logMessage
|
||||||
|
|
||||||
object ScrapingService {
|
object ScrapingService {
|
||||||
|
|
||||||
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, Strings.LOG_TRENDS_START)
|
||||||
val trendsUrl = "https://trends.google.co.kr/trends/trendingsearches/daily?geo=KR"
|
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 options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
|
||||||
val currentDriver = BrowserManager.getChromeDriver(options)
|
val currentDriver = BrowserManager.getChromeDriver(options)
|
||||||
return try {
|
return try {
|
||||||
currentDriver.get(trendsUrl)
|
currentDriver.get(trendsUrl)
|
||||||
Thread.sleep(5000)
|
Thread.sleep(5000)
|
||||||
currentDriver.findElements(By.xpath("//tr[count(td)=7]/td[2]")).mapNotNull { it.text.takeIf(String::isNotBlank) }.also { logMessage(logs, "✅ Google Trends 키워드 ${it.size}개 스크랩 완료.") }
|
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) {
|
} catch (e: Exception) {
|
||||||
logMessage(logs, "❌ Google Trends 스크랩 오류: ${e.message}"); BrowserManager.quitChromeDriver(); emptyList()
|
logMessage(logs, Strings.logTrendsError(e.message)); BrowserManager.quitChromeDriver(); emptyList()
|
||||||
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
|
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> {
|
suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): List<SearchResult> {
|
||||||
logMessage(logs, "'$keyword' 키워드로 Google 검색 시작...")
|
logMessage(logs, Strings.logSearchStart(keyword))
|
||||||
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
|
val options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
|
||||||
val currentDriver = BrowserManager.getChromeDriver(options)
|
val currentDriver = BrowserManager.getChromeDriver(options)
|
||||||
val results = mutableListOf<SearchResult>()
|
val results = mutableListOf<SearchResult>()
|
||||||
@ -39,27 +42,49 @@ object ScrapingService {
|
|||||||
if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url))
|
if (title.isNotBlank() && url.isNotBlank()) results.add(SearchResult(title, url))
|
||||||
} catch (e: Exception) { /* 개별 오류 무시 */ }
|
} catch (e: Exception) { /* 개별 오류 무시 */ }
|
||||||
}
|
}
|
||||||
logMessage(logs, "✅ '$keyword' 검색 결과 ${results.size}개 수집 완료.")
|
logMessage(logs, Strings.logSearchSuccess(keyword, results.size))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logMessage(logs, "❌ Google 검색 중 오류: ${e.message}"); BrowserManager.quitChromeDriver()
|
logMessage(logs, Strings.logSearchError(e.message)); BrowserManager.quitChromeDriver()
|
||||||
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
|
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? {
|
// ⭐️ [수정] 함수 시그니처에 selectors 파라미터 추가
|
||||||
logMessage(logs, "URL 스크랩 시작: $url")
|
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 options = ChromeOptions().apply { if (!isBrowserVisible) addArguments("--headless=new"); addArguments("--disable-gpu") }
|
||||||
val currentDriver = BrowserManager.getChromeDriver(options)
|
val currentDriver = BrowserManager.getChromeDriver(options)
|
||||||
return try {
|
return try {
|
||||||
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("#app, .app, article, .article-body, #article_body, .news-article-body-view").text()
|
|
||||||
val allImages = doc.select("article img, .article-body img, #article_body img, .news-article-body-view img").map { it.absUrl("src") }.filter { it.isNotBlank() && it.startsWith("http") }.distinct()
|
// ⭐️ [수정] 인자로 받은 selectors 리스트를 쉼표로 연결하여 Jsoup 쿼리로 사용하고, 첫 번째 요소를 찾습니다.
|
||||||
if (articleContent.isBlank()) { logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다."); null }
|
val articleElement = doc.select(selectors.joinToString(", "))?.firstOrNull()
|
||||||
else { logMessage(logs, "✅ URL 스크랩 완료. (이미지 ${allImages.size}개 발견)"); ScrapedData(sourceUrl = url, selectedImageUrls = allImages.take(1), allImageUrls = allImages, content = articleContent) }
|
|
||||||
|
if (articleElement == null) {
|
||||||
|
logMessage(logs, Strings.LOG_WARN_ARTICLE_BODY_NOT_FOUND)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 찾은 요소에서 텍스트와 이미지를 각각 추출합니다.
|
||||||
|
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) {
|
} catch (e: Exception) {
|
||||||
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}"); BrowserManager.quitChromeDriver(); null
|
logMessage(logs, Strings.logScrapeUrlError(e.message)); BrowserManager.quitChromeDriver(); null
|
||||||
} finally { if (!keepSession) BrowserManager.quitChromeDriver() }
|
} finally {
|
||||||
|
if (!keepSession) BrowserManager.quitChromeDriver()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -12,10 +12,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import core.FileManager
|
|
||||||
import core.FileManager.loadScrapedJsonFiles
|
|
||||||
import core.FileManager.saveDataToJsonFile
|
|
||||||
import core.LlmApiService
|
|
||||||
import core.LlmApiService.analyzeReceipts
|
import core.LlmApiService.analyzeReceipts
|
||||||
import core.LlmApiService.cleanupWorkspace
|
import core.LlmApiService.cleanupWorkspace
|
||||||
import core.LlmApiService.generateBlogPost
|
import core.LlmApiService.generateBlogPost
|
||||||
@ -34,56 +30,67 @@ import models.ScrapedData
|
|||||||
import models.SearchResult
|
import models.SearchResult
|
||||||
import ui.tabs.*
|
import ui.tabs.*
|
||||||
import ui.widgets.FileDialog
|
import ui.widgets.FileDialog
|
||||||
|
import utils.FileManager.loadScrapedJsonFiles
|
||||||
|
import utils.FileManager.saveDataToJsonFile
|
||||||
|
import utils.Global
|
||||||
import utils.Global.jsonParser
|
import utils.Global.jsonParser
|
||||||
|
import utils.Strings
|
||||||
import utils.logMessage
|
import utils.logMessage
|
||||||
import java.awt.Toolkit
|
import java.awt.Toolkit
|
||||||
import java.awt.datatransfer.StringSelection
|
import java.awt.datatransfer.StringSelection
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.prefs.Preferences
|
import java.util.prefs.Preferences
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.text.isBlank
|
|
||||||
|
|
||||||
// (기존 App Composable의 상태 변수 선언 및 로직을 여기에 모두 이동)
|
|
||||||
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
|
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
|
||||||
const val PREF_FOLDER_PATH = "folder_path"
|
const val PREF_FOLDER_PATH = Global.PREF_FOLDER_PATH
|
||||||
const val PREF_API_KEY = "api_key"
|
const val PREF_API_KEY = Global.PREF_API_KEY
|
||||||
const val PREF_WORKSPACE_SLUG = "workspace_slug"
|
const val PREF_WORKSPACE_SLUG = Global.PREF_WORKSPACE_SLUG
|
||||||
const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
|
const val PREF_RECEIPT_WORKSPACE_SLUG = Global.PREF_RECEIPT_WORKSPACE_SLUG
|
||||||
const val PREF_MODEL_NAME = "model_name"
|
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
|
||||||
|
|
||||||
|
|
||||||
// --- UI 컴포넌트 ---
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App(imageLoader: ImageLoader) {
|
fun App(imageLoader: ImageLoader) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var tabIndex by remember { mutableStateOf(0) }
|
var tabIndex by remember { mutableStateOf(0) }
|
||||||
val tabs = listOf("스크랩 기반 포스팅", "직접 포스팅", "영수증 분석기", "통신 로그", "블로그 결과")
|
val tabs = Strings.TABS + "설정"
|
||||||
var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
|
|
||||||
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
|
|
||||||
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
|
|
||||||
var blogPostFooter by remember { mutableStateOf("") }
|
|
||||||
val logMessages = remember { mutableStateListOf<String>() }
|
val logMessages = remember { mutableStateListOf<String>() }
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
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 selectedKeyword by remember { mutableStateOf("") }
|
||||||
var userPrompt by remember { mutableStateOf("개인 기록용으로 가볍게 남기는 스타일로 작성해줘.") }
|
var userPrompt by remember { mutableStateOf(Strings.DEFAULT_USER_PROMPT) }
|
||||||
var isBrowserVisible by remember { mutableStateOf(true) }
|
var isBrowserVisible by remember { mutableStateOf(true) }
|
||||||
var keepBrowserSession by remember { mutableStateOf(true) }
|
var keepBrowserSession by remember { mutableStateOf(true) }
|
||||||
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) }
|
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, Strings.DEFAULT_SCRAPED_FOLDER)) }
|
||||||
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
|
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
|
||||||
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my-workspace")) }
|
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, Strings.DEFAULT_WORKSPACE_SLUG)) }
|
||||||
var receiptWorkspaceSlug by remember { mutableStateOf(prefs.get(PREF_RECEIPT_WORKSPACE_SLUG, "receipts")) }
|
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, "Llama-3.1-8B-Vision")) }
|
var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, Strings.DEFAULT_MODEL_NAME)) }
|
||||||
var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
|
var scrapedFiles by remember { mutableStateOf<List<File>>(emptyList()) }
|
||||||
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
|
var selectedFiles by remember { mutableStateOf<Set<File>>(emptySet()) }
|
||||||
var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
|
var currentlyOpenFile by remember { mutableStateOf<File?>(null) }
|
||||||
var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
|
var viewedFileContent by remember { mutableStateOf(Strings.PLACEHOLDER_FILE_CONTENT) }
|
||||||
var imagesForSelection by remember { mutableStateOf<List<String>>(emptyList()) }
|
var combinedImagesFromSelectedFiles by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var currentSelectedImages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
var manualKeyword by remember { mutableStateOf("") }
|
var manualKeyword by remember { mutableStateOf("") }
|
||||||
var userOwnContent by remember { mutableStateOf("예시: 강릉으로 1박 2일 여행을 다녀왔습니다.") }
|
var userOwnContent by remember { mutableStateOf(Strings.DEFAULT_USER_OWN_CONTENT) }
|
||||||
var isImageUploadDialogVisible by remember { mutableStateOf(false) }
|
var isImageUploadDialogVisible by remember { mutableStateOf(false) }
|
||||||
var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) }
|
var uploadedImageFiles by remember { mutableStateOf<List<File>>(emptyList()) }
|
||||||
var receiptFiles by remember { mutableStateOf<List<File>>(emptyList()) }
|
var receiptFiles by remember { mutableStateOf<List<File>>(emptyList()) }
|
||||||
var receiptAnalysisResult by remember { mutableStateOf("영수증을 업로드하고 분석을 시작하세요.") }
|
var receiptAnalysisResult by remember { mutableStateOf(Strings.PLACEHOLDER_RECEIPT_ANALYSIS) }
|
||||||
var isReceiptDialogVisible by remember { mutableStateOf(false) }
|
var isReceiptDialogVisible by remember { mutableStateOf(false) }
|
||||||
var analysisJob by remember { mutableStateOf<Job?>(null) }
|
var analysisJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var receiptContextPrompt by remember { mutableStateOf("") }
|
var receiptContextPrompt by remember { mutableStateOf("") }
|
||||||
@ -91,79 +98,186 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
var userMainTopic by remember { mutableStateOf("") }
|
var userMainTopic by remember { mutableStateOf("") }
|
||||||
var revisionRequest by remember { mutableStateOf("") }
|
var revisionRequest by remember { mutableStateOf("") }
|
||||||
|
|
||||||
LaunchedEffect(scrapedFolderPath) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages); prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) }
|
// --- 프롬프트 및 설정 상태 변수 ---
|
||||||
|
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(apiKey) { prefs.put(PREF_API_KEY, apiKey) }
|
||||||
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
|
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
|
||||||
LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) }
|
LaunchedEffect(receiptWorkspaceSlug) { prefs.put(PREF_RECEIPT_WORKSPACE_SLUG, receiptWorkspaceSlug) }
|
||||||
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
|
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
|
||||||
|
// ⭐️ [추가] 셀렉터 목록이 변경될 때마다 자동으로 Preferences에 저장
|
||||||
|
LaunchedEffect(articleSelectors) {
|
||||||
|
prefs.put(PREF_ARTICLE_SELECTORS, articleSelectors.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
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}개 추가됨.") } } }
|
// --- 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 {
|
MaterialTheme {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// --- 상단 설정 UI ---
|
||||||
Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) {
|
Column(modifier = Modifier.padding(8.dp).border(1.dp, Color.LightGray).padding(8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = isBrowserVisible, onCheckedChange = { isBrowserVisible = it }); Text("브라우저 화면 보기", modifier = Modifier.clickable { isBrowserVisible = !isBrowserVisible }.weight(1f)); Checkbox(checked = keepBrowserSession, onCheckedChange = { keepBrowserSession = it }); Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f)) }
|
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("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true); Spacer(Modifier.height(4.dp)); OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation()); Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(8.dp)); OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text(Strings.LABEL_SCRAP_FOLDER_PATH) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
Row(Modifier.fillMaxWidth()) { OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("블로그용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true); Spacer(Modifier.width(4.dp)); OutlinedTextField(value = receiptWorkspaceSlug, onValueChange = { receiptWorkspaceSlug = it }, label = { Text("영수증 처리용 Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true) }
|
Spacer(Modifier.height(4.dp)); OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text(Strings.PLACEHOLDER_API_KEY) }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation())
|
||||||
Spacer(Modifier.height(4.dp)); OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("사용 중인 LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
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()
|
Divider()
|
||||||
|
|
||||||
|
// --- 탭 ---
|
||||||
TabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } }
|
TabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index }) } }
|
||||||
|
|
||||||
|
// --- 탭별 컨텐츠 ---
|
||||||
when (tabIndex) {
|
when (tabIndex) {
|
||||||
0 -> ScrapBasedPostTab(
|
0 -> ScrapBasedPostTab(
|
||||||
isLoading, keywords, searchResults, scrapedFiles, selectedFiles, viewedFileContent, imagesForSelection,
|
isLoading, keywords, searchResults, scrapedFiles, selectedFiles, viewedFileContent,
|
||||||
|
combinedImagesFromSelectedFiles,
|
||||||
currentSelectedImages, userPrompt, imageLoader, manualKeyword, userScrapComment, userMainTopic,
|
currentSelectedImages, userPrompt, imageLoader, manualKeyword, userScrapComment, userMainTopic,
|
||||||
onManualKeywordChange = { manualKeyword = it }, onUserPromptChange = { userPrompt = it }, onUserScrapCommentChange = { userScrapComment = it }, onUserMainTopicChange = { userMainTopic = it },
|
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 } },
|
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 } },
|
onKeywordSelect = { keyword -> selectedKeyword = keyword; manualKeyword = keyword; coroutineScope.launch(Dispatchers.IO) { isLoading = true; searchResults = searchOnGoogle(keyword, logMessages, isBrowserVisible, keepBrowserSession); isLoading = false } },
|
||||||
onSearchResultSelect = { result -> coroutineScope.launch(Dispatchers.IO) { isLoading = true; scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)?.let { data -> val finalKeyword = if (selectedKeyword.isNotBlank()) selectedKeyword else manualKeyword; val savedFile = saveDataToJsonFile(finalKeyword, data, scrapedFolderPath,logMessages); scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages); currentlyOpenFile = savedFile; viewedFileContent = data.content; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() }; 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) } },
|
onRefreshFiles = { coroutineScope.launch(Dispatchers.IO) { scrapedFiles = loadScrapedJsonFiles( scrapedFolderPath,logMessages) } },
|
||||||
onFileSelectToggle = { file, isSelected -> selectedFiles = if (isSelected) selectedFiles + file else selectedFiles - file },
|
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; imagesForSelection = data.allImageUrls; currentSelectedImages = data.selectedImageUrls.toSet() } catch (e: Exception) { logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}") } } },
|
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 },
|
onImageSelect = { imageUrl -> currentSelectedImages = if (imageUrl in currentSelectedImages) currentSelectedImages - imageUrl else currentSelectedImages + imageUrl },
|
||||||
onSaveChanges = { coroutineScope.launch(Dispatchers.IO) { currentlyOpenFile?.let { file -> try { val originalData = jsonParser.decodeFromString<ScrapedData>(file.readText()); val updatedData = originalData.copy(selectedImageUrls = currentSelectedImages.toList()); file.writeText(jsonParser.encodeToString(updatedData)); logMessage(logMessages, "✅ '${file.name}'의 선택 이미지 변경사항 저장 완료.") } catch (e: Exception) { logMessage(logMessages, "❌ 파일 저장 중 오류: ${e.message}") } } } },
|
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 = {
|
onGeneratePost = {
|
||||||
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@ScrapBasedPostTab }
|
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@ScrapBasedPostTab }
|
||||||
if (selectedFiles.isEmpty()) { logMessage(logMessages, "⚠️ 글을 생성할 스크랩 파일을 1개 이상 선택해주세요."); return@ScrapBasedPostTab }
|
if (selectedFiles.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_NO_FILES_SELECTED); return@ScrapBasedPostTab }
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
try {
|
try {
|
||||||
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
|
val selectedDataList = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
|
||||||
val allImages = selectedDataList.flatMap { it.selectedImageUrls }.distinct()
|
val allImages = currentSelectedImages.toList()
|
||||||
val uploadedDocIds = uploadFiles(selectedFiles.toList(), apiKey, workspaceSlug,logMessages)
|
val uploadedDocIds = uploadFiles(selectedFiles.toList(), apiKey, workspaceSlug, logMessages)
|
||||||
if (uploadedDocIds.isEmpty()) { logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다."); return@launch }
|
if (uploadedDocIds.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_UPLOAD_FAILED); return@launch }
|
||||||
|
|
||||||
val resultText = generateBlogPost(selectedDataList, "", allImages, userPrompt, userScrapComment, userMainTopic, apiKey, workspaceSlug,logMessages)
|
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 {
|
val footer = buildString {
|
||||||
appendLine("\n\n---")
|
appendLine(Strings.Footer.SEPARATOR)
|
||||||
if (selectedDataList.isNotEmpty()) {
|
if (selectedDataList.isNotEmpty()) {
|
||||||
appendLine("- 원문 출처:")
|
appendLine(Strings.Footer.ORIGINAL_SOURCE_TITLE)
|
||||||
selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") }
|
selectedDataList.forEach { appendLine(" - ${it.sourceUrl}") }
|
||||||
}
|
}
|
||||||
if (allImages.isNotEmpty()) {
|
if (allImages.isNotEmpty()) {
|
||||||
appendLine("\n- 사용된 이미지:")
|
appendLine(Strings.Footer.USED_IMAGES_TITLE)
|
||||||
allImages.forEach { appendLine(" - $it") }
|
allImages.forEach { appendLine(" - $it") }
|
||||||
}
|
}
|
||||||
|
appendLine(Strings.Footer.PROCESS_TITLE)
|
||||||
appendLine("\n\n[이 글의 작성 과정]")
|
|
||||||
val contextSummary = mutableListOf<String>()
|
val contextSummary = mutableListOf<String>()
|
||||||
if (userMainTopic.isNotBlank()) contextSummary.add("주제: '${userMainTopic}'")
|
if (userMainTopic.isNotBlank()) contextSummary.add(Strings.Footer.contextTopic(userMainTopic))
|
||||||
if (userScrapComment.isNotBlank()) contextSummary.add("작성자 코멘트: '${userScrapComment.take(30)}...'")
|
if (userScrapComment.isNotBlank()) contextSummary.add(Strings.Footer.contextComment(userScrapComment))
|
||||||
if (userPrompt.isNotBlank()) contextSummary.add("요청 스타일: '${userPrompt.take(30)}...'")
|
if (userPrompt.isNotBlank()) contextSummary.add(Strings.Footer.contextStyle(userPrompt))
|
||||||
|
val contextText = if (contextSummary.isNotEmpty()) "${contextSummary.joinToString(", ")} " else ""
|
||||||
append("이 포스팅은 ")
|
append(Strings.Footer.scrapBased(contextText, modelName))
|
||||||
if (contextSummary.isNotEmpty()) append("${contextSummary.joinToString(", ")} 등의 정보를 바탕으로, ")
|
|
||||||
append("여러 참고 자료를 종합하여 ${modelName} AI 모델이 초안을 생성했습니다. 이후 작성자의 검토를 거쳐 수정 및 발행되었습니다.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
blogPostResult = resultText
|
||||||
blogPostFooter = footer
|
blogPostFooter = footer
|
||||||
tabIndex = 4
|
tabIndex = tabs.indexOf("블로그 결과")
|
||||||
cleanupWorkspace(uploadedDocIds, apiKey,logMessages)
|
cleanupWorkspace(uploadedDocIds, apiKey, logMessages)
|
||||||
} finally { isLoading = false }
|
} finally { isLoading = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -179,31 +293,58 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
onUploadImage = { isImageUploadDialogVisible = true },
|
onUploadImage = { isImageUploadDialogVisible = true },
|
||||||
onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove },
|
onRemoveUploadedImage = { fileToRemove -> uploadedImageFiles = uploadedImageFiles - fileToRemove },
|
||||||
onGeneratePost = {
|
onGeneratePost = {
|
||||||
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요."); return@DirectPostTab }
|
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@DirectPostTab }
|
||||||
if (userOwnContent.isBlank() || uploadedImageFiles.isEmpty()) { logMessage(logMessages, "⚠️ 직접 포스팅을 하려면 내용과 이미지가 모두 필요합니다."); return@DirectPostTab }
|
if (userOwnContent.isBlank() && uploadedImageFiles.isEmpty()) { logMessage(logMessages, Strings.LOG_WARN_DIRECT_POST_EMPTY); return@DirectPostTab }
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
try {
|
try {
|
||||||
val allImages = uploadedImageFiles.map { it.toURI().toString() }
|
val allImages = uploadedImageFiles.map { it.toURI().toString() }
|
||||||
val resultText = generateBlogPost(emptyList(), userOwnContent, allImages, userPrompt, "", "", apiKey, workspaceSlug,logMessages)
|
|
||||||
|
|
||||||
// ⭐️ [수정] 컨텍스트를 포함한 꼬리말 생성 (직접 포스팅용)
|
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 {
|
val footer = buildString {
|
||||||
appendLine("\n\n---")
|
appendLine(Strings.Footer.SEPARATOR)
|
||||||
if (allImages.isNotEmpty()) {
|
if (allImages.isNotEmpty()) {
|
||||||
appendLine("- 사용된 이미지:")
|
appendLine(Strings.Footer.USED_IMAGES_TITLE)
|
||||||
allImages.forEach { appendLine(" - $it") }
|
allImages.forEach { appendLine(" - $it") }
|
||||||
}
|
}
|
||||||
|
appendLine(Strings.Footer.PROCESS_TITLE)
|
||||||
appendLine("\n\n[이 글의 작성 과정]")
|
val postscript = if (userPrompt.isNotBlank()) Strings.Footer.directPost(userPrompt, modelName)
|
||||||
append("이 포스팅은 작성자가 직접 입력한 내용을 바탕으로, ")
|
else Strings.Footer.directPostNoPrompt(modelName)
|
||||||
if (userPrompt.isNotBlank()) append("'${userPrompt.take(30)}...' 스타일로 문체와 구성을 다듬도록 요청하여, ")
|
append(postscript)
|
||||||
append("${modelName} AI 모델의 도움을 받아 완성되었습니다.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
blogPostResult = resultText
|
||||||
blogPostFooter = footer
|
blogPostFooter = footer
|
||||||
tabIndex = 4
|
tabIndex = tabs.indexOf("블로그 결과")
|
||||||
} finally { isLoading = false }
|
} finally { isLoading = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -220,10 +361,10 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
val resultFlow = MutableStateFlow("")
|
val resultFlow = MutableStateFlow("")
|
||||||
val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } }
|
val uiUpdateJob = launch(Dispatchers.Main) { resultFlow.collect { newResult -> receiptAnalysisResult = newResult } }
|
||||||
analyzeReceipts(receiptFiles, apiKey, receiptWorkspaceSlug, receiptContextPrompt,logMessages, resultFlow)
|
analyzeReceipts(receiptFiles, apiKey, receiptWorkspaceSlug, receiptContextPrompt, receiptPromptBase, logMessages, resultFlow)
|
||||||
uiUpdateJob.cancel()
|
uiUpdateJob.cancel()
|
||||||
} catch (e: CancellationException) { logMessage(logMessages, "ℹ️ 영수증 분석이 사용자에 의해 중단되었습니다."); receiptAnalysisResult = "분석이 중단되었습니다."
|
} catch (e: CancellationException) { logMessage(logMessages, Strings.LOG_INFO_ANALYSIS_CANCELLED); receiptAnalysisResult = Strings.ANALYSIS_STOPPED
|
||||||
} catch (e: Exception) { logMessage(logMessages, "❌ 영수증 분석 중 심각한 오류 발생: ${e.message}"); receiptAnalysisResult = "오류 발생: ${e.message}"
|
} catch (e: Exception) { logMessage(logMessages, Strings.logAnalysisError(e.message)); receiptAnalysisResult = Strings.analysisError(e.message)
|
||||||
} finally { isLoading = false; analysisJob = null }
|
} finally { isLoading = false; analysisJob = null }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -237,14 +378,11 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
onRevisionRequestChange = { revisionRequest = it },
|
onRevisionRequestChange = { revisionRequest = it },
|
||||||
isLoading = isLoading,
|
isLoading = isLoading,
|
||||||
onRevise = {
|
onRevise = {
|
||||||
if (apiKey.isBlank() || workspaceSlug.isBlank()) {
|
if (apiKey.isBlank() || workspaceSlug.isBlank()) { logMessage(logMessages, Strings.LOG_WARN_MISSING_API_CONFIG); return@ResultTab }
|
||||||
logMessage(logMessages, "⚠️ API 키와 블로그용 Workspace Slug를 모두 입력해주세요.")
|
|
||||||
return@ResultTab
|
|
||||||
}
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
try {
|
try {
|
||||||
val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug,logMessages)
|
val revisedText = reviseBlogPost(blogPostResult, revisionRequest, apiKey, workspaceSlug, revisePromptPrefix, logMessages)
|
||||||
blogPostResult = revisedText
|
blogPostResult = revisedText
|
||||||
revisionRequest = ""
|
revisionRequest = ""
|
||||||
} finally {
|
} finally {
|
||||||
@ -253,11 +391,40 @@ fun App(imageLoader: ImageLoader) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCopyToClipboard = {
|
onCopyToClipboard = {
|
||||||
val fullContent = blogPostResult + blogPostFooter
|
val fullContent = blogPostResult + blogPostFooter + generationContextForClipboard
|
||||||
val stringSelection = StringSelection(fullContent)
|
val stringSelection = StringSelection(fullContent)
|
||||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
Toolkit.getDefaultToolkit().systemClipboard.setContents(stringSelection, null)
|
||||||
clipboard.setContents(stringSelection, null)
|
logMessage(logMessages, Strings.LOG_CLIPBOARD_COPY_SUCCESS)
|
||||||
logMessage(logMessages, "✅ 블로그 전체 내용(꼬리말 포함)이 클립보드에 복사되었습니다.")
|
}
|
||||||
|
)
|
||||||
|
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, "ℹ️ 프롬프트 및 설정을 기본값으로 초기화했습니다.")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// ui/tabs/ScrapBasedPostTab.kt
|
// ui/tabs/tabs.kt
|
||||||
package ui.tabs
|
package ui.tabs
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
@ -8,10 +8,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
// (필요한 import 추가)
|
|
||||||
import androidx.compose.material.*
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -20,15 +22,17 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.compose.rememberAsyncImagePainter
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
import io.ktor.client.request.url
|
|
||||||
import models.SearchResult
|
import models.SearchResult
|
||||||
|
import utils.Strings
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScrapBasedPostTab(
|
fun ScrapBasedPostTab(
|
||||||
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>, currentSelectedImages: Set<String>,
|
viewedFileContent: String,
|
||||||
|
combinedImagesFromSelectedFiles: List<String>,
|
||||||
|
currentSelectedImages: Set<String>,
|
||||||
userPrompt: String, imageLoader: ImageLoader, manualKeyword: String, userScrapComment: String, userMainTopic: String,
|
userPrompt: String, imageLoader: ImageLoader, manualKeyword: String, userScrapComment: String, userMainTopic: String,
|
||||||
onManualKeywordChange: (String) -> Unit, onUserPromptChange: (String) -> Unit, onUserScrapCommentChange: (String) -> Unit, onUserMainTopicChange: (String) -> Unit,
|
onManualKeywordChange: (String) -> Unit, onUserPromptChange: (String) -> Unit, onUserScrapCommentChange: (String) -> Unit, onUserMainTopicChange: (String) -> Unit,
|
||||||
onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit,
|
onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit,
|
||||||
@ -40,12 +44,12 @@ fun ScrapBasedPostTab(
|
|||||||
// 1. 키워드 및 검색
|
// 1. 키워드 및 검색
|
||||||
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)) {
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text("키워드 직접 입력") }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading)
|
OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text(Strings.LABEL_KEYWORD_INPUT) }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text("검색") }
|
Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text(Strings.BUTTON_SEARCH) }
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text("트렌드 가져오기") }
|
Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text(Strings.BUTTON_FETCH_TRENDS) }
|
||||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||||
items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) }
|
items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) }
|
||||||
@ -53,7 +57,7 @@ fun ScrapBasedPostTab(
|
|||||||
}
|
}
|
||||||
// 2. 검색 결과 및 스크랩
|
// 2. 검색 결과 및 스크랩
|
||||||
Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(4.dp)) {
|
Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(4.dp)) {
|
||||||
Text("검색 결과 (클릭하여 스크랩)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
|
Text(Strings.TITLE_SEARCH_RESULTS, style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(searchResults) { result ->
|
items(searchResults) { result ->
|
||||||
Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) {
|
||||||
@ -68,8 +72,8 @@ fun ScrapBasedPostTab(
|
|||||||
// 저장된 파일
|
// 저장된 파일
|
||||||
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("저장된 파일", style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
|
Text(Strings.TITLE_SAVED_FILES, style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp))
|
||||||
Button(onClick = onRefreshFiles, enabled = !isLoading) { Text("새로고침") }
|
Button(onClick = onRefreshFiles, enabled = !isLoading) { Text(Strings.BUTTON_REFRESH) }
|
||||||
}
|
}
|
||||||
Box(modifier = Modifier.heightIn(max = 200.dp)) {
|
Box(modifier = Modifier.heightIn(max = 200.dp)) {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
@ -82,15 +86,15 @@ fun ScrapBasedPostTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("파일 내용", style = MaterialTheme.typography.subtitle1)
|
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)
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("대표 이미지 선택 (다중 가능)", style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f))
|
Text(Strings.TITLE_SELECT_IMAGES, style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f))
|
||||||
Button(onClick = onSaveChanges, enabled = !isLoading && imagesForSelection.isNotEmpty()) { Text("선택 이미지 저장") }
|
Button(onClick = onSaveChanges, enabled = !isLoading && combinedImagesFromSelectedFiles.isNotEmpty()) { Text(Strings.BUTTON_SAVE_IMAGE_SELECTION) }
|
||||||
}
|
}
|
||||||
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) {
|
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) {
|
||||||
items(imagesForSelection) { imageUrl ->
|
items(combinedImagesFromSelectedFiles) { imageUrl ->
|
||||||
val isSelected = imageUrl in currentSelectedImages
|
val isSelected = imageUrl in currentSelectedImages
|
||||||
Box(modifier = Modifier.padding(4.dp)) {
|
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)
|
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)
|
||||||
@ -102,23 +106,23 @@ fun ScrapBasedPostTab(
|
|||||||
|
|
||||||
// 글 생성 제어
|
// 글 생성 제어
|
||||||
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(8.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(8.dp)) {
|
||||||
Text("LLM 요청사항", style = MaterialTheme.typography.h6)
|
Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6)
|
||||||
OutlinedTextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") })
|
OutlinedTextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_PROMPT) })
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = userMainTopic,
|
value = userMainTopic,
|
||||||
onValueChange = onUserMainTopicChange,
|
onValueChange = onUserMainTopicChange,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("글의 핵심 주제 (예: 2025년 최신 IT 트렌드)") },
|
label = { Text(Strings.LABEL_MAIN_TOPIC) },
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedTextField(value = userScrapComment, onValueChange = onUserScrapCommentChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text("작성자 코멘트 (스크랩한 내용에 대한 당신의 생각)") })
|
OutlinedTextField(value = userScrapComment, onValueChange = onUserScrapCommentChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_COMMENT) })
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("선택한 파일(${selectedFiles.size}개)로 글 생성")
|
Text(Strings.buttonGeneratePost(selectedFiles.size))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,19 +149,19 @@ fun DirectPostTab(
|
|||||||
Row(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
Row(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||||
// 1. 내용 작성
|
// 1. 내용 작성
|
||||||
Column(modifier = Modifier.weight(2f).padding(end = 8.dp)) {
|
Column(modifier = Modifier.weight(2f).padding(end = 8.dp)) {
|
||||||
Text("직접 작성 (여행 기록, 정보 공유 등)", style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
|
Text(Strings.TITLE_DIRECT_POST, style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = userOwnContent,
|
value = userOwnContent,
|
||||||
onValueChange = onUserOwnContentChange,
|
onValueChange = onUserOwnContentChange,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
label = { Text("블로그에 올릴 내용을 직접 작성하세요.") }
|
label = { Text(Strings.LABEL_DIRECT_POST_CONTENT) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 이미지 및 생성 제어
|
// 2. 이미지 및 생성 제어
|
||||||
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(8.dp)) {
|
Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(8.dp)) {
|
||||||
Text("이미지 업로드", style = MaterialTheme.typography.h6)
|
Text(Strings.TITLE_IMAGE_UPLOAD, style = MaterialTheme.typography.h6)
|
||||||
Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text("내 PC에서 이미지 업로드") }
|
Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_IMAGE) }
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) {
|
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) {
|
||||||
items(uploadedImageFiles) { file ->
|
items(uploadedImageFiles) { file ->
|
||||||
@ -173,19 +177,19 @@ fun DirectPostTab(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Text("LLM 요청사항", style = MaterialTheme.typography.h6)
|
Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = userPrompt,
|
value = userPrompt,
|
||||||
onValueChange = onUserPromptChange,
|
onValueChange = onUserPromptChange,
|
||||||
modifier = Modifier.fillMaxWidth().height(150.dp),
|
modifier = Modifier.fillMaxWidth().height(150.dp),
|
||||||
label = { Text("글 스타일, 톤앤매너 등을 지시하세요.") }
|
label = { Text(Strings.LABEL_USER_PROMPT) }
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onGeneratePost,
|
onClick = onGeneratePost,
|
||||||
enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(),
|
enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) { Text("작성한 내용으로 글 생성") }
|
) { Text(Strings.BUTTON_GENERATE_POST_DIRECT) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
|
if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
|
||||||
@ -209,8 +213,8 @@ fun ReceiptAnalyzerTab(
|
|||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Text("영수증 분석기", style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 8.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("영수증 이미지 업로드") }
|
Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_RECEIPT) }
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) {
|
LazyRow(modifier = Modifier.fillMaxWidth().height(120.dp)) {
|
||||||
items(receiptFiles) { file ->
|
items(receiptFiles) { file ->
|
||||||
@ -229,8 +233,8 @@ fun ReceiptAnalyzerTab(
|
|||||||
value = receiptContextPrompt,
|
value = receiptContextPrompt,
|
||||||
onValueChange = onReceiptContextPromptChange,
|
onValueChange = onReceiptContextPromptChange,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = { Text("추가 정보를 입력하면 더 정확하게 분석할 수 있습니다.") },
|
placeholder = { Text(Strings.PLACEHOLDER_RECEIPT_CONTEXT) },
|
||||||
label = { Text("추가 정보 입력 (예: 부산 출장 경비)") }
|
label = { Text(Strings.LABEL_RECEIPT_CONTEXT) }
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
if (isAnalyzing) {
|
if (isAnalyzing) {
|
||||||
@ -238,12 +242,12 @@ fun ReceiptAnalyzerTab(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("분석 중단하기")
|
Text(Strings.BUTTON_CANCEL_ANALYSIS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("선택한 영수증 분석 시작 (${receiptFiles.size}개)")
|
Text(Strings.buttonStartAnalysis(receiptFiles.size))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
@ -252,7 +256,7 @@ fun ReceiptAnalyzerTab(
|
|||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
label = { Text("분석 결과 (내용 복사하여 사용)") }
|
label = { Text(Strings.LABEL_ANALYSIS_RESULT) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
|
if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
|
||||||
@ -281,15 +285,15 @@ fun ResultTab(
|
|||||||
value = result,
|
value = result,
|
||||||
onValueChange = onRequestResultChange,
|
onValueChange = onRequestResultChange,
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
label = { Text("블로그 글 결과 (LLM 생성 본문)") }
|
label = { Text(Strings.LABEL_BLOG_RESULT) }
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = revisionRequest,
|
value = revisionRequest,
|
||||||
onValueChange = onRevisionRequestChange,
|
onValueChange = onRevisionRequestChange,
|
||||||
modifier = Modifier.fillMaxWidth().height(100.dp),
|
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||||
label = { Text("추가 요청사항") },
|
label = { Text(Strings.LABEL_REVISION_REQUEST) },
|
||||||
placeholder = { Text("예: 문체를 좀 더 전문적으로 바꿔줘. 1번 항목을 더 자세히 설명해줘.") },
|
placeholder = { Text(Strings.PLACEHOLDER_REVISION_REQUEST) },
|
||||||
enabled = !isLoading
|
enabled = !isLoading
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@ -302,7 +306,7 @@ fun ResultTab(
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Text("LLM으로 글 보완하기")
|
Text(Strings.BUTTON_REVISE_POST)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
@ -311,9 +315,172 @@ fun ResultTab(
|
|||||||
enabled = !isLoading && result.isNotBlank(),
|
enabled = !isLoading && result.isNotBlank(),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Text("전체 내용 클립보드에 복사")
|
Text(Strings.BUTTON_COPY_TO_CLIPBOARD)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⭐️ [수정] SettingsTab 함수 시그니처 변경
|
||||||
|
@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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ package ui.widgets
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import utils.Strings
|
||||||
import java.awt.FileDialog
|
import java.awt.FileDialog
|
||||||
import java.awt.Frame
|
import java.awt.Frame
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -11,7 +12,7 @@ import java.io.File
|
|||||||
@Composable
|
@Composable
|
||||||
fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List<File>) -> Unit) {
|
fun FileDialog(parent: Frame? = null, onCloseRequest: (result: List<File>) -> Unit) {
|
||||||
val fileDialog = remember {
|
val fileDialog = remember {
|
||||||
FileDialog(parent, "파일 선택", FileDialog.LOAD).apply {
|
FileDialog(parent, Strings.FILE_DIALOG_TITLE, FileDialog.LOAD).apply {
|
||||||
isMultipleMode = true
|
isMultipleMode = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,13 @@ import io.ktor.client.engine.cio.*
|
|||||||
import io.ktor.client.plugins.*
|
import io.ktor.client.plugins.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
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.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.prefs.Preferences
|
import java.util.prefs.Preferences
|
||||||
@ -25,9 +31,272 @@ object Global {
|
|||||||
const val PREF_WORKSPACE_SLUG = "workspace_slug"
|
const val PREF_WORKSPACE_SLUG = "workspace_slug"
|
||||||
const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
|
const val PREF_RECEIPT_WORKSPACE_SLUG = "receipt_workspace_slug"
|
||||||
const val PREF_MODEL_NAME = "model_name"
|
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) {
|
fun logMessage(logs: MutableList<String>, message: String) {
|
||||||
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
|
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
|
||||||
logs.add(0, "$timestamp: $message")
|
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(
|
||||||
|
"#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)}...'"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user