...
This commit is contained in:
parent
b95aa050ad
commit
871741b764
@ -17,7 +17,6 @@ import androidx.compose.ui.window.application
|
|||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.engine.cio.*
|
import io.ktor.client.engine.cio.*
|
||||||
// ⭐️ [추가] 타임아웃 설정을 위한 import
|
|
||||||
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.client.request.*
|
import io.ktor.client.request.*
|
||||||
@ -29,6 +28,7 @@ import io.ktor.serialization.kotlinx.json.*
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.openqa.selenium.By
|
import org.openqa.selenium.By
|
||||||
@ -57,22 +57,32 @@ data class DeleteDocumentsRequest(val deletes: List<String>)
|
|||||||
|
|
||||||
data class SearchResult(val title: String, val url: String)
|
data class SearchResult(val title: String, val url: String)
|
||||||
|
|
||||||
|
// ⭐️ [추가] 스크랩된 모든 정보를 담는 데이터 클래스 (JSON 저장용)
|
||||||
|
@Serializable
|
||||||
|
data class ScrapedData(
|
||||||
|
val sourceUrl: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val content: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
// --- 전역 변수 및 헬퍼 ---
|
// --- 전역 변수 및 헬퍼 ---
|
||||||
// ⭐️ [수정] HttpClient에 타임아웃 설정 추가
|
|
||||||
private val httpClient = HttpClient(CIO) {
|
private val httpClient = HttpClient(CIO) {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true })
|
json(Json { isLenient = true; ignoreUnknownKeys = true; prettyPrint = true })
|
||||||
}
|
}
|
||||||
// LLM이 응답할 시간을 5분으로 넉넉하게 설정
|
|
||||||
install(HttpTimeout) {
|
install(HttpTimeout) {
|
||||||
requestTimeoutMillis = 300000
|
requestTimeoutMillis = 300000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private val jsonParser = Json { prettyPrint = true; ignoreUnknownKeys = true }
|
||||||
private var driver: ChromeDriver? = null
|
private var driver: ChromeDriver? = null
|
||||||
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
|
private val prefs: Preferences = Preferences.userNodeForPackage(Unit::class.java)
|
||||||
private const val PREF_FOLDER_PATH = "folder_path"
|
private const val PREF_FOLDER_PATH = "folder_path"
|
||||||
private const val PREF_API_KEY = "api_key"
|
private const val PREF_API_KEY = "api_key"
|
||||||
private const val PREF_WORKSPACE_SLUG = "workspace_slug"
|
private const val PREF_WORKSPACE_SLUG = "workspace_slug"
|
||||||
|
// ⭐️ [추가] LLM 모델 이름 저장을 위한 키
|
||||||
|
private const val PREF_MODEL_NAME = "model_name"
|
||||||
|
|
||||||
|
|
||||||
fun logMessage(logs: MutableList<String>, message: String) {
|
fun logMessage(logs: MutableList<String>, message: String) {
|
||||||
@ -154,7 +164,8 @@ suspend fun searchOnGoogle(keyword: String, logs: MutableList<String>, isBrowser
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): String {
|
// ⭐️ [수정] 반환 타입을 ScrapedData로 변경하고 이미지 URL 추출 로직 추가
|
||||||
|
suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowserVisible: Boolean, keepSession: Boolean): ScrapedData? {
|
||||||
logMessage(logs, "URL 스크랩 시작: $url")
|
logMessage(logs, "URL 스크랩 시작: $url")
|
||||||
val options = ChromeOptions().apply {
|
val options = ChromeOptions().apply {
|
||||||
if (!isBrowserVisible) addArguments("--headless=new")
|
if (!isBrowserVisible) addArguments("--headless=new")
|
||||||
@ -166,17 +177,24 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowser
|
|||||||
Thread.sleep(2000)
|
Thread.sleep(2000)
|
||||||
val doc = Jsoup.parse(currentDriver.pageSource)
|
val doc = Jsoup.parse(currentDriver.pageSource)
|
||||||
val articleContent = doc.select("article, .article-body, #article_body, .news-article-body-view").text()
|
val articleContent = doc.select("article, .article-body, #article_body, .news-article-body-view").text()
|
||||||
|
// OG 태그에서 대표 이미지 URL 추출
|
||||||
|
val imageUrl = doc.select("meta[property=og:image]").attr("content")
|
||||||
|
|
||||||
if (articleContent.isBlank()) {
|
if (articleContent.isBlank()) {
|
||||||
logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.")
|
logMessage(logs, "⚠️ 기사 본문을 찾을 수 없습니다.")
|
||||||
"기사 본문을 찾을 수 없습니다."
|
null
|
||||||
} else {
|
} else {
|
||||||
logMessage(logs, "✅ URL 스크랩 완료. (총 ${articleContent.length}자)")
|
logMessage(logs, "✅ URL 스크랩 완료. (이미지: ${if (imageUrl.isNotBlank()) "있음" else "없음"})")
|
||||||
articleContent
|
ScrapedData(
|
||||||
|
sourceUrl = url,
|
||||||
|
imageUrl = imageUrl.ifBlank { null },
|
||||||
|
content = articleContent
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}")
|
logMessage(logs, "❌ URL 스크랩 중 오류: ${e.message}")
|
||||||
quitChromeDriver()
|
quitChromeDriver()
|
||||||
"페이지 스크랩 중 오류 발생: ${e.message}"
|
null
|
||||||
} finally {
|
} finally {
|
||||||
if (!keepSession) quitChromeDriver()
|
if (!keepSession) quitChromeDriver()
|
||||||
}
|
}
|
||||||
@ -185,7 +203,7 @@ suspend fun scrapeArticleByUrl(url: String, logs: MutableList<String>, isBrowser
|
|||||||
|
|
||||||
suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> {
|
suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKey: String, workspaceSlug: String): List<String> {
|
||||||
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
|
logMessage(logs, "LLM에 파일 ${files.size}개 순차 업로드 시작...")
|
||||||
val uploadedDocLocations = mutableListOf<String>()
|
val uploadedDocIds = mutableListOf<String>()
|
||||||
|
|
||||||
for (file in files) {
|
for (file in files) {
|
||||||
logMessage(logs, " - '${file.name}' 업로드 중...")
|
logMessage(logs, " - '${file.name}' 업로드 중...")
|
||||||
@ -195,7 +213,9 @@ suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKe
|
|||||||
setBody(MultiPartFormDataContent(
|
setBody(MultiPartFormDataContent(
|
||||||
formData {
|
formData {
|
||||||
append("addToWorkspaces", workspaceSlug)
|
append("addToWorkspaces", workspaceSlug)
|
||||||
append("file", file.readBytes(), Headers.build {
|
// ⭐️ [수정] JSON 파일의 내용을 텍스트로 읽어 전송
|
||||||
|
val scrapedData = jsonParser.decodeFromString<ScrapedData>(file.readText())
|
||||||
|
append("file", scrapedData.content.toByteArray(), Headers.build {
|
||||||
append(HttpHeaders.ContentType, ContentType.Text.Plain)
|
append(HttpHeaders.ContentType, ContentType.Text.Plain)
|
||||||
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
|
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
|
||||||
})
|
})
|
||||||
@ -205,10 +225,10 @@ suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKe
|
|||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
val uploadResponse = response.body<UploadResponse>()
|
val uploadResponse = response.body<UploadResponse>()
|
||||||
val location = uploadResponse.documents.firstOrNull()?.location
|
val docId = uploadResponse.documents.firstOrNull()?.id
|
||||||
if (location != null) {
|
if (docId != null) {
|
||||||
uploadedDocLocations.add(location)
|
uploadedDocIds.add(docId)
|
||||||
logMessage(logs, " ✅ '${file.name}' 업로드 성공.")
|
logMessage(logs, " ✅ '${file.name}' 업로드 성공 (ID: $docId).")
|
||||||
} else {
|
} else {
|
||||||
logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
|
logMessage(logs, " ❌ '${file.name}' 업로드 응답에 문서 정보가 없습니다.")
|
||||||
}
|
}
|
||||||
@ -220,18 +240,45 @@ suspend fun uploadFilesToLLM(files: List<File>, logs: MutableList<String>, apiKe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logMessage(logs, "총 ${files.size}개 중 ${uploadedDocLocations.size}개 파일 업로드 완료.")
|
logMessage(logs, "총 ${files.size}개 중 ${uploadedDocIds.size}개 파일 업로드 완료.")
|
||||||
return uploadedDocLocations
|
return uploadedDocIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⭐️ [수정] ScrapedData 리스트와 modelName을 파라미터로 받도록 변경
|
||||||
suspend fun generateBlogPostWithLocalLLM(userDirection: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
|
suspend fun generateBlogPostWithLocalLLM(scrapedDataList: List<ScrapedData>, userDirection: String, modelName: String, logs: MutableList<String>, apiKey: String, workspaceSlug: String): String {
|
||||||
logMessage(logs, "LLM 블로그 글 생성 요청...")
|
logMessage(logs, "LLM 블로그 글 생성 요청...")
|
||||||
|
|
||||||
|
// ⭐️ [수정] LLM에게 전달할 참고자료 형식 변경
|
||||||
|
val referencesText = scrapedDataList.mapIndexed { index, data ->
|
||||||
|
"""
|
||||||
|
[참고자료 ${index + 1}]
|
||||||
|
- 원문 출처: ${data.sourceUrl}
|
||||||
|
- 대표 이미지: ${data.imageUrl ?: "없음"}
|
||||||
|
- 내용 요약: ${data.content.take(500)}...
|
||||||
|
""".trimIndent()
|
||||||
|
}.joinToString("\n\n")
|
||||||
|
|
||||||
|
// ⭐️ [수정] 최종 프롬프트 수정
|
||||||
val finalPrompt = """
|
val finalPrompt = """
|
||||||
내 지식 베이스에 있는 문서들을 종합적으로 참고해서 아래 '요청사항'에 맞춰 SEO에 최적화된 블로그 글을 작성해줘. 제목도 2~3개 추천해줘.
|
당신은 최신 정보를 종합하여 SEO에 최적화된 블로그 글을 작성하는 전문 작가입니다.
|
||||||
|
아래 '참고자료'와 '요청사항'을 바탕으로, 독자들이 흥미를 느낄만한 멋진 블로그 글을 작성해주세요.
|
||||||
|
|
||||||
|
--- 참고자료 ---
|
||||||
|
$referencesText
|
||||||
|
|
||||||
--- 요청사항 ---
|
--- 요청사항 ---
|
||||||
$userDirection
|
1. 사용자 요청: "$userDirection"
|
||||||
|
2. 참고자료의 내용을 종합적으로 활용하여 하나의 완성된 글을 작성해주세요.
|
||||||
|
3. 글의 시작 부분에 독자의 시선을 사로잡을 만한 제목을 2~3개 추천해주세요.
|
||||||
|
4. 대표 이미지가 있는 경우, 글의 적절한 위치에 마크다운 형식 ``으로 이미지를 삽입해주세요.
|
||||||
|
5. 글의 마지막에는 아래 형식에 맞춰 출처 정보를 반드시 포함해주세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
- 원문 출처:
|
||||||
|
${scrapedDataList.map { "- ${it.sourceUrl}" }.joinToString("\n")}
|
||||||
|
- 이미지 출처: ${scrapedDataList.firstNotNullOfOrNull { it.imageUrl } ?: "없음"}
|
||||||
|
- 이 글은 ${modelName} 모델을 활용하여 작성되었습니다.
|
||||||
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val requestBody = AnythingLLMChatRequest(message = finalPrompt)
|
val requestBody = AnythingLLMChatRequest(message = finalPrompt)
|
||||||
@ -245,7 +292,8 @@ suspend fun generateBlogPostWithLocalLLM(userDirection: String, logs: MutableLis
|
|||||||
val responseBodyText = response.bodyAsText()
|
val responseBodyText = response.bodyAsText()
|
||||||
logMessage(logs, "LLM 응답 수신: $responseBodyText")
|
logMessage(logs, "LLM 응답 수신: $responseBodyText")
|
||||||
|
|
||||||
val chatResponse = Json.decodeFromString<AnythingLLMChatResponse>(responseBodyText)
|
val chatResponse = jsonParser.decodeFromString<AnythingLLMChatResponse>(responseBodyText)
|
||||||
|
|
||||||
logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
|
logMessage(logs, "✅ LLM 블로그 글 생성 완료.")
|
||||||
return chatResponse.textResponse
|
return chatResponse.textResponse
|
||||||
|
|
||||||
@ -255,16 +303,14 @@ suspend fun generateBlogPostWithLocalLLM(userDirection: String, logs: MutableLis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐️ [수정] cleanup 함수의 URL을 올바른 주소로 변경
|
suspend fun cleanupLLMWorkspace(docIds: List<String>, logs: MutableList<String>, apiKey: String) {
|
||||||
suspend fun cleanupLLMWorkspace(locations: List<String>, logs: MutableList<String>, apiKey: String) {
|
if (docIds.isEmpty()) return
|
||||||
if (locations.isEmpty()) return
|
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${docIds.size}개 삭제)...")
|
||||||
logMessage(logs, "LLM 워크스페이스 정리 시작 (파일 ${locations.size}개 삭제)...")
|
|
||||||
try {
|
try {
|
||||||
// [수정] URL을 /workspace/{slug}/update-documents 에서 /documents/delete 로 변경
|
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/document/delete") {
|
||||||
val response: HttpResponse = httpClient.post("http://localhost:3001/api/v1/documents/delete") {
|
|
||||||
header("Authorization", "Bearer $apiKey")
|
header("Authorization", "Bearer $apiKey")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(DeleteDocumentsRequest(deletes = locations))
|
setBody(DeleteDocumentsRequest(deletes = docIds))
|
||||||
}
|
}
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
logMessage(logs, "✅ LLM 워크스페이스 정리 완료.")
|
logMessage(logs, "✅ LLM 워크스페이스 정리 완료.")
|
||||||
@ -276,22 +322,24 @@ suspend fun cleanupLLMWorkspace(locations: List<String>, logs: MutableList<Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⭐️ [수정] ScrapedData를 JSON 파일로 저장하는 함수
|
||||||
fun saveContentToFile(keyword: String, content: String, logs: MutableList<String>, folderPath: String) {
|
fun saveDataToJsonFile(keyword: String, data: ScrapedData, logs: MutableList<String>, folderPath: String) {
|
||||||
try {
|
try {
|
||||||
val directory = File(folderPath)
|
val directory = File(folderPath)
|
||||||
if (!directory.exists()) directory.mkdirs()
|
if (!directory.exists()) directory.mkdirs()
|
||||||
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
|
val sanitizedKeyword = keyword.replace(Regex("[^A-Za-z0-9ㄱ-ㅎㅏ-ㅣ가-힣]"), "")
|
||||||
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.txt"
|
// ⭐️ [수정] 확장자를 .json으로 변경
|
||||||
|
val fileName = "${sanitizedKeyword}_${System.currentTimeMillis()}.json"
|
||||||
val file = File(directory, fileName)
|
val file = File(directory, fileName)
|
||||||
file.writeText(content)
|
file.writeText(jsonParser.encodeToString(data))
|
||||||
logMessage(logs, "✅ '${file.path}'에 스크랩 내용 저장 완료.")
|
logMessage(logs, "✅ '${file.path}'에 스크랩 데이터 저장 완료.")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logMessage(logs, "❌ 파일 저장 중 오류: ${e.message}")
|
logMessage(logs, "❌ 파일 저장 중 오류: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadScrapedFiles(logs: MutableList<String>, folderPath: String): List<File> {
|
// ⭐️ [수정] .json 파일을 읽어오는 함수
|
||||||
|
fun loadScrapedJsonFiles(logs: MutableList<String>, folderPath: String): List<File> {
|
||||||
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
|
logMessage(logs, "'$folderPath'에서 파일 목록 로딩...")
|
||||||
return try {
|
return try {
|
||||||
val directory = File(folderPath)
|
val directory = File(folderPath)
|
||||||
@ -299,7 +347,8 @@ fun loadScrapedFiles(logs: MutableList<String>, folderPath: String): List<File>
|
|||||||
logMessage(logs, "⚠️ '$folderPath' 폴더를 찾을 수 없습니다.")
|
logMessage(logs, "⚠️ '$folderPath' 폴더를 찾을 수 없습니다.")
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
val files = directory.listFiles { _, name -> name.endsWith(".txt") }
|
// ⭐️ [수정] .json 파일만 대상으로 함
|
||||||
|
val files = directory.listFiles { _, name -> name.endsWith(".json") }
|
||||||
?.sortedByDescending { it.lastModified() }
|
?.sortedByDescending { it.lastModified() }
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
logMessage(logs, "✅ 파일 ${files.size}개 로딩 완료.")
|
logMessage(logs, "✅ 파일 ${files.size}개 로딩 완료.")
|
||||||
@ -310,7 +359,6 @@ fun loadScrapedFiles(logs: MutableList<String>, folderPath: String): List<File>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- UI 컴포넌트 ---
|
// --- UI 컴포넌트 ---
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
@ -318,7 +366,6 @@ fun App() {
|
|||||||
val tabs = listOf("워크플로우", "통신 로그", "블로그 결과")
|
val tabs = listOf("워크플로우", "통신 로그", "블로그 결과")
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
// --- 상태 관리 ---
|
|
||||||
var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
|
var keywords by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
|
var searchResults by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
|
||||||
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
|
var blogPostResult by remember { mutableStateOf("LLM으로부터 생성된 블로그 글이 여기에 표시됩니다.") }
|
||||||
@ -327,33 +374,26 @@ fun App() {
|
|||||||
var selectedKeyword by remember { mutableStateOf("") }
|
var selectedKeyword by remember { mutableStateOf("") }
|
||||||
var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") }
|
var userPrompt by remember { mutableStateOf("친근하고 유용한 정보 전달 스타일로 작성해줘.") }
|
||||||
|
|
||||||
// 설정 상태
|
|
||||||
var isBrowserVisible by remember { mutableStateOf(true) }
|
var isBrowserVisible by remember { mutableStateOf(true) }
|
||||||
var keepBrowserSession by remember { mutableStateOf(false) }
|
var keepBrowserSession by remember { mutableStateOf(false) }
|
||||||
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) }
|
var scrapedFolderPath by remember { mutableStateOf(prefs.get(PREF_FOLDER_PATH, "scraped_articles")) }
|
||||||
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
|
var apiKey by remember { mutableStateOf(prefs.get(PREF_API_KEY, "")) }
|
||||||
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my")) }
|
var workspaceSlug by remember { mutableStateOf(prefs.get(PREF_WORKSPACE_SLUG, "my")) }
|
||||||
|
// ⭐️ [추가] 모델 이름 상태 변수
|
||||||
|
var modelName by remember { mutableStateOf(prefs.get(PREF_MODEL_NAME, "gpt-4o")) }
|
||||||
|
|
||||||
// 파일 관리 상태
|
|
||||||
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 viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
|
var viewedFileContent by remember { mutableStateOf("파일을 선택하면 내용이 여기에 표시됩니다.") }
|
||||||
|
|
||||||
LaunchedEffect(scrapedFolderPath) {
|
LaunchedEffect(scrapedFolderPath) {
|
||||||
scrapedFiles = loadScrapedFiles(logMessages, scrapedFolderPath)
|
scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(scrapedFolderPath) {
|
|
||||||
prefs.put(PREF_FOLDER_PATH, scrapedFolderPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(apiKey) {
|
|
||||||
prefs.put(PREF_API_KEY, apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(workspaceSlug) {
|
|
||||||
prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug)
|
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(scrapedFolderPath) { prefs.put(PREF_FOLDER_PATH, scrapedFolderPath) }
|
||||||
|
LaunchedEffect(apiKey) { prefs.put(PREF_API_KEY, apiKey) }
|
||||||
|
LaunchedEffect(workspaceSlug) { prefs.put(PREF_WORKSPACE_SLUG, workspaceSlug) }
|
||||||
|
// ⭐️ [추가] 모델 이름 변경 시 자동 저장
|
||||||
|
LaunchedEffect(modelName) { prefs.put(PREF_MODEL_NAME, modelName) }
|
||||||
|
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
@ -365,30 +405,16 @@ fun App() {
|
|||||||
Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f))
|
Text("브라우저 세션 유지", modifier = Modifier.clickable { keepBrowserSession = !keepBrowserSession }.weight(1f))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(value = scrapedFolderPath, onValueChange = { scrapedFolderPath = it }, label = { Text("스크랩 저장 폴더 경로") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
value = scrapedFolderPath,
|
|
||||||
onValueChange = { scrapedFolderPath = it },
|
|
||||||
label = { Text("스크랩 저장 폴더 경로") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
OutlinedTextField(
|
Row(Modifier.fillMaxWidth()) {
|
||||||
value = apiKey,
|
OutlinedTextField(value = apiKey, onValueChange = { apiKey = it }, label = { Text("AnythingLLM API Key") }, modifier = Modifier.weight(1f), singleLine = true, visualTransformation = PasswordVisualTransformation())
|
||||||
onValueChange = { apiKey = it },
|
Spacer(Modifier.width(4.dp))
|
||||||
label = { Text("AnythingLLM API Key") },
|
OutlinedTextField(value = workspaceSlug, onValueChange = { workspaceSlug = it }, label = { Text("Workspace Slug") }, modifier = Modifier.weight(1f), singleLine = true)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation()
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
OutlinedTextField(
|
// ⭐️ [추가] 모델 이름 입력 필드
|
||||||
value = workspaceSlug,
|
OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text("LLM 모델 이름") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
onValueChange = { workspaceSlug = it },
|
|
||||||
label = { Text("AnythingLLM Workspace Slug") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@ -397,7 +423,6 @@ fun App() {
|
|||||||
Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index })
|
Tab(text = { Text(title) }, selected = tabIndex == index, onClick = { tabIndex = index })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (tabIndex) {
|
when (tabIndex) {
|
||||||
0 -> WorkflowTab(
|
0 -> WorkflowTab(
|
||||||
isLoading = isLoading, keywords = keywords, searchResults = searchResults,
|
isLoading = isLoading, keywords = keywords, searchResults = searchResults,
|
||||||
@ -421,17 +446,18 @@ fun App() {
|
|||||||
onSearchResultSelect = { result ->
|
onSearchResultSelect = { result ->
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
val content = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)
|
// ⭐️ [수정] ScrapedData를 받아 JSON으로 저장
|
||||||
if (!content.contains("오류 발생")) {
|
val data = scrapeArticleByUrl(result.url, logMessages, isBrowserVisible, keepBrowserSession)
|
||||||
saveContentToFile(selectedKeyword, content, logMessages, scrapedFolderPath)
|
if (data != null) {
|
||||||
scrapedFiles = loadScrapedFiles(logMessages, scrapedFolderPath)
|
saveDataToJsonFile(selectedKeyword, data, logMessages, scrapedFolderPath)
|
||||||
|
scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
|
||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRefreshFiles = {
|
onRefreshFiles = {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
scrapedFiles = loadScrapedFiles(logMessages, scrapedFolderPath)
|
scrapedFiles = loadScrapedJsonFiles(logMessages, scrapedFolderPath)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFileSelectToggle = { file, isSelected ->
|
onFileSelectToggle = { file, isSelected ->
|
||||||
@ -440,7 +466,14 @@ fun App() {
|
|||||||
onFileView = { file ->
|
onFileView = { file ->
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
viewedFileContent = Path(file.absolutePath).readText(Charsets.UTF_8)
|
// ⭐️ [수정] JSON을 읽어 본문 내용만 표시
|
||||||
|
val data = jsonParser.decodeFromString<ScrapedData>(Path(file.absolutePath).readText(Charsets.UTF_8))
|
||||||
|
viewedFileContent = """
|
||||||
|
[원문] ${data.sourceUrl}
|
||||||
|
[이미지] ${data.imageUrl ?: "없음"}
|
||||||
|
--------------------
|
||||||
|
${data.content}
|
||||||
|
""".trimIndent()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}")
|
logMessage(logMessages, "❌ 파일 읽기 오류: ${e.message}")
|
||||||
viewedFileContent = "파일을 읽는 중 오류가 발생했습니다."
|
viewedFileContent = "파일을 읽는 중 오류가 발생했습니다."
|
||||||
@ -448,30 +481,29 @@ fun App() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onGeneratePost = {
|
onGeneratePost = {
|
||||||
if (apiKey.isBlank()) {
|
if (apiKey.isBlank() || workspaceSlug.isBlank() || modelName.isBlank()) {
|
||||||
logMessage(logMessages, "⚠️ API 키를 먼저 입력해주세요.")
|
logMessage(logMessages, "⚠️ API 키, 슬러그, 모델 이름을 모두 입력해주세요.")
|
||||||
return@WorkflowTab
|
|
||||||
}
|
|
||||||
if (workspaceSlug.isBlank()) {
|
|
||||||
logMessage(logMessages, "⚠️ 워크스페이스 슬러그를 먼저 입력해주세요.")
|
|
||||||
return@WorkflowTab
|
return@WorkflowTab
|
||||||
}
|
}
|
||||||
if (selectedFiles.isNotEmpty()) {
|
if (selectedFiles.isNotEmpty()) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
var uploadedLocations: List<String> = emptyList()
|
var uploadedDocIds: List<String> = emptyList()
|
||||||
try {
|
try {
|
||||||
uploadedLocations = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug)
|
// ⭐️ [수정] 선택된 JSON 파일들을 ScrapedData로 변환
|
||||||
if (uploadedLocations.isNotEmpty()) {
|
val selectedData = selectedFiles.map { jsonParser.decodeFromString<ScrapedData>(it.readText()) }
|
||||||
blogPostResult = generateBlogPostWithLocalLLM(userPrompt, logMessages, apiKey, workspaceSlug)
|
|
||||||
|
uploadedDocIds = uploadFilesToLLM(selectedFiles.toList(), logMessages, apiKey, workspaceSlug)
|
||||||
|
if (uploadedDocIds.isNotEmpty()) {
|
||||||
|
// ⭐️ [수정] ScrapedData 리스트와 모델 이름을 전달
|
||||||
|
blogPostResult = generateBlogPostWithLocalLLM(selectedData, userPrompt, modelName, logMessages, apiKey, workspaceSlug)
|
||||||
tabIndex = 2
|
tabIndex = 2
|
||||||
} else {
|
} else {
|
||||||
logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.")
|
logMessage(logMessages, "⚠️ 파일 업로드에 실패하여 글 생성을 중단합니다.")
|
||||||
blogPostResult = "파일 업로드 실패"
|
blogPostResult = "파일 업로드 실패"
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// [수정] cleanup 함수에서 더 이상 slug가 필요 없음
|
cleanupLLMWorkspace(uploadedDocIds, logMessages, apiKey)
|
||||||
cleanupLLMWorkspace(uploadedLocations, logMessages, apiKey)
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -569,7 +601,7 @@ fun main() = application {
|
|||||||
httpClient.close()
|
httpClient.close()
|
||||||
exitApplication()
|
exitApplication()
|
||||||
},
|
},
|
||||||
title = "자동 블로그 포스팅 도우미 v3.1"
|
title = "자동 블로그 포스팅 도우미 v4.0 (Final)"
|
||||||
) {
|
) {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user