diff --git a/core/src/main/kotlin/com/simiacryptus/cognotik/input/DocumentReader.kt b/core/src/main/kotlin/com/simiacryptus/cognotik/input/DocumentReader.kt index 44ec0bfce..80c7d6406 100644 --- a/core/src/main/kotlin/com/simiacryptus/cognotik/input/DocumentReader.kt +++ b/core/src/main/kotlin/com/simiacryptus/cognotik/input/DocumentReader.kt @@ -18,7 +18,7 @@ interface RenderableDocumentReader : DocumentReader { fun renderImage(pageIndex: Int, dpi: Float): BufferedImage } -fun File.getReader(): DocumentReader = when { +fun File.getDocumentReader(): DocumentReader = when { this.name.endsWith(".pdf", ignoreCase = true) -> PDFReader(this) this.name.endsWith(".docx", ignoreCase = true) -> DocxReader(this) this.name.endsWith(".doc", ignoreCase = true) -> DocReader(this) @@ -32,4 +32,12 @@ fun File.getReader(): DocumentReader = when { this.name.endsWith(".htm", ignoreCase = true) -> HTMLReader(this) this.name.endsWith(".eml", ignoreCase = true) -> EmlReader(this) else -> TextReader(this) +} + +fun File.isDocumentFile(): Boolean { + val supportedExtensions = listOf( + ".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".ppt", + ".odt", ".rtf", ".html", ".htm", ".eml", ".txt" + ) + return supportedExtensions.any { this.name.endsWith(it, ignoreCase = true) } } \ No newline at end of file diff --git a/core/src/main/kotlin/com/simiacryptus/cognotik/input/EmlReader.kt b/core/src/main/kotlin/com/simiacryptus/cognotik/input/EmlReader.kt index 1965fcd97..774561aa8 100644 --- a/core/src/main/kotlin/com/simiacryptus/cognotik/input/EmlReader.kt +++ b/core/src/main/kotlin/com/simiacryptus/cognotik/input/EmlReader.kt @@ -107,7 +107,7 @@ class EmlReader(file: File) : DocumentReader { } } // Use the file extension to determine the appropriate reader - val attachmentReader = tempFile.getReader() + val attachmentReader = tempFile.getDocumentReader() attachmentReader.use { val attachmentText = it.getText() result.appendLine(attachmentText) diff --git a/core/src/main/kotlin/com/simiacryptus/cognotik/util/FileSelectionUtils.kt b/core/src/main/kotlin/com/simiacryptus/cognotik/util/FileSelectionUtils.kt index 290032d62..2db14609e 100644 --- a/core/src/main/kotlin/com/simiacryptus/cognotik/util/FileSelectionUtils.kt +++ b/core/src/main/kotlin/com/simiacryptus/cognotik/util/FileSelectionUtils.kt @@ -1,5 +1,6 @@ package com.simiacryptus.cognotik.util +import com.simiacryptus.cognotik.input.isDocumentFile import org.apache.commons.text.similarity.LevenshteinDistance import java.io.File import java.io.InputStream @@ -18,7 +19,7 @@ fun filteredWalkAsciiTree( ): String { val sb = StringBuilder() val filterFn = if (treatDocumentsAsText) { - { file: File -> fn(file) && isDocumentFile(file) } + { file: File -> fn(file) && file.isDocumentFile() } } else fn if (!filterFn(rootFile)) { log.debug("Skipping root file for tree: ${rootFile.absolutePath}") @@ -77,7 +78,7 @@ fun filteredWalkAsciiTree( fn: (File) -> Boolean = { !isLLMIgnored(it.toPath()) } ): List { val filterFn = if (treatDocumentsAsText) { - { f: File -> fn(f) || isDocumentFile(f) } + { f: File -> fn(f) || f.isDocumentFile() } } else fn val result = mutableListOf() if (filterFn(file)) { @@ -115,7 +116,7 @@ fun filteredWalkAsciiTree( } (when { it.name.endsWith(".data") -> arrayOf(it) - treatDocumentsAsText && isDocumentFile(it) -> arrayOf(it) + treatDocumentsAsText && it.isDocumentFile() -> arrayOf(it) isGitignore(it.toPath()) -> { log.debug("File ignored by gitignore: ${it.absolutePath}") arrayOf() @@ -156,7 +157,7 @@ fun filteredWalkAsciiTree( !file.exists() -> false file.isDirectory -> false file.name.endsWith(".data") -> true - treatDocumentsAsText && isDocumentFile(file) -> true + treatDocumentsAsText && file.isDocumentFile() -> true file.length() > 100_000_000L -> false // 100MB limit isGitignore(file.toPath()) -> false isLLMIgnored(file.toPath()) -> false @@ -384,11 +385,6 @@ fun filteredWalkAsciiTree( this } - fun isDocumentFile(file: File): Boolean { - val extension = file.extension.lowercase(Locale.getDefault()) - return extension in setOf("pdf", "html", "htm") - } - fun resolveToRelativePath(root: Path, filename: String): String? { log.debug("Resolving filename '{}' relative to root '{}'", filename, root) if (!root.toFile().exists() || !root.toFile().isDirectory) { diff --git a/desktop/src/main/resources/welcome/welcome.html b/desktop/src/main/resources/welcome/welcome.html index 06f6a5a4a..d40084d26 100644 --- a/desktop/src/main/resources/welcome/welcome.html +++ b/desktop/src/main/resources/welcome/welcome.html @@ -246,6 +246,7 @@

Step 2: Configure Settings

+
diff --git a/desktop/src/main/resources/welcome/welcome.js b/desktop/src/main/resources/welcome/welcome.js index 829255f8c..d8558f54d 100644 --- a/desktop/src/main/resources/welcome/welcome.js +++ b/desktop/src/main/resources/welcome/welcome.js @@ -402,13 +402,32 @@ function setupWizardNavigation() { tempValue.textContent = this.value; }); } - // Setup working directory generator +// Setup working directory generator document.getElementById('generate-working-dir')?.addEventListener('click', () => { const workingDirInput = document.getElementById('working-dir'); if (workingDirInput) { workingDirInput.value = Utils.generateCognotikWorkingDir(); } }); + // Setup working directory selector + // document.getElementById('select-working-dir')?.addEventListener('click', () => { + // const workingDirInput = document.getElementById('working-dir'); + // if (workingDirInput) { + // // Create a file input element to trigger directory selection + // const fileInput = document.createElement('input'); + // fileInput.type = 'file'; + // fileInput.webkitdirectory = true; + // fileInput.directory = true; + // fileInput.addEventListener('change', (e) => { + // if (e.target.files && e.target.files.length > 0) { + // // Get the path from the first file's webkitRelativePath + // const firstFilePath = e.target.files[0].webkitRelativePath; + // workingDirInput.value = firstFilePath; + // } + // }); + // fileInput.click(); + // } + // }); // Next buttons document.getElementById('next-to-task-settings')?.addEventListener('click', () => { diff --git a/gradle.properties b/gradle.properties index 0e173af16..a623fb44d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ pluginName=Cognotik pluginRepositoryUrl=https://github.com/SimiaCryptus/Cognotik libraryGroup=com.simiacryptus -libraryVersion=2.0.26 +libraryVersion=2.0.27 gradleVersion=8.13 org.gradle.caching=true diff --git a/intellij/src/main/kotlin/cognotik/actions/agent/CustomFileSetPatchServer.kt b/intellij/src/main/kotlin/cognotik/actions/agent/CustomFileSetPatchServer.kt index 8195b2531..8f629b2fe 100644 --- a/intellij/src/main/kotlin/cognotik/actions/agent/CustomFileSetPatchServer.kt +++ b/intellij/src/main/kotlin/cognotik/actions/agent/CustomFileSetPatchServer.kt @@ -4,7 +4,7 @@ import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.config.AppSettingsState import com.simiacryptus.cognotik.diff.PatchProcessor -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.models.ModelSchema import com.simiacryptus.cognotik.platform.Session import com.simiacryptus.cognotik.platform.model.User @@ -671,7 +671,7 @@ class CustomFileSetPatchServer( ignoreCase = true ) || file.name.endsWith(".htm", ignoreCase = true) -> { try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> reader.getText() } } catch (e: Exception) { diff --git a/intellij/src/main/kotlin/cognotik/actions/chat/MultiCodeChatAction.kt b/intellij/src/main/kotlin/cognotik/actions/chat/MultiCodeChatAction.kt index 00fcd4d27..67c45e3d3 100644 --- a/intellij/src/main/kotlin/cognotik/actions/chat/MultiCodeChatAction.kt +++ b/intellij/src/main/kotlin/cognotik/actions/chat/MultiCodeChatAction.kt @@ -10,7 +10,7 @@ import com.simiacryptus.cognotik.CognotikAppServer import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.chat.model.ChatInterface import com.simiacryptus.cognotik.config.AppSettingsState -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.models.ModelSchema import com.simiacryptus.cognotik.platform.ApplicationServices import com.simiacryptus.cognotik.platform.Session @@ -231,7 +231,7 @@ class MultiCodeChatAction : BaseAction() { fun readFileContent(file: File): String { return try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> reader.getText() } } catch (e: Exception) { diff --git a/intellij/src/main/kotlin/cognotik/actions/task/BusinessProposalAction.kt b/intellij/src/main/kotlin/cognotik/actions/task/BusinessProposalAction.kt new file mode 100644 index 000000000..3ded3638d --- /dev/null +++ b/intellij/src/main/kotlin/cognotik/actions/task/BusinessProposalAction.kt @@ -0,0 +1,480 @@ +package cognotik.actions.task + +import cognotik.actions.BaseAction +import cognotik.actions.agent.toFile +import cognotik.actions.plan.PlanConfigDialog +import cognotik.actions.plan.toApiChatModel +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import com.simiacryptus.cognotik.CognotikAppServer +import com.simiacryptus.cognotik.apps.general.SingleTaskApp +import com.simiacryptus.cognotik.config.AppSettingsState +import com.simiacryptus.cognotik.config.instance +import com.simiacryptus.cognotik.plan.AbstractTask.TaskState +import com.simiacryptus.cognotik.plan.OrchestrationConfig +import com.simiacryptus.cognotik.plan.tools.writing.BusinessProposalTask +import com.simiacryptus.cognotik.platform.ApplicationServices +import com.simiacryptus.cognotik.platform.Session +import com.simiacryptus.cognotik.platform.file.DataStorage +import com.simiacryptus.cognotik.platform.file.UserSettingsManager +import com.simiacryptus.cognotik.platform.model.ApiChatModel +import com.simiacryptus.cognotik.util.* +import com.simiacryptus.cognotik.util.BrowseUtil.browse +import com.simiacryptus.cognotik.webui.application.AppInfoData +import com.simiacryptus.cognotik.webui.application.ApplicationServer +import java.awt.Dimension +import java.io.File +import java.text.SimpleDateFormat +import javax.swing.* + +class BusinessProposalAction : BaseAction() { + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + override fun isEnabled(event: AnActionEvent): Boolean { + if (!super.isEnabled(event)) return false + if (event.getSelectedFiles().isEmpty() && event.getSelectedFolder() == null) return false + return true + } + + override fun handle(e: AnActionEvent) { + val root = getProjectRoot(e) ?: return + val relatedFiles = getFiles(e) + val dialog = BusinessProposalDialog( + e.project, root, relatedFiles + ) + + if (dialog.showAndGet()) { + try { + val taskConfig = dialog.getTaskConfig() + val orchestrationConfig = dialog.getOrchestrationConfig() + + UITools.runAsync(e.project, "Initializing Business Proposal Task", true) { progress -> + initializeTask(e, progress, orchestrationConfig, taskConfig, root) + } + } catch (ex: Exception) { + log.error("Failed to initialize business proposal task", ex) + UITools.showError(e.project, "Failed to initialize task: ${ex.message}") + } + } + } + + private fun initializeTask( + e: AnActionEvent, + progress: ProgressIndicator, + orchestrationConfig: OrchestrationConfig, + taskConfig: BusinessProposalTask.BusinessProposalTaskExecutionConfigData, + root: File + ) { + progress.text = "Setting up session..." + val session = Session.newGlobalID() + + DataStorage.sessionPaths[session] = root + + progress.text = "Starting server..." + setupTaskSession(session, orchestrationConfig, taskConfig, root) + + Thread { + Thread.sleep(500) + try { + val uri = CognotikAppServer.getServer().server.uri.resolve("/#$session") + log.info("Opening browser to $uri") + browse(uri) + } catch (e: Throwable) { + log.warn("Error opening browser", e) + } + }.start() + } + + private fun setupTaskSession( + session: Session, + orchestrationConfig: OrchestrationConfig, + taskConfig: BusinessProposalTask.BusinessProposalTaskExecutionConfigData, + root: File + ) { + val app = object : SingleTaskApp( + applicationName = "Business Proposal Generation", + path = "/businessProposal", + showMenubar = false, + taskType = BusinessProposalTask.BusinessProposal, + taskConfig = taskConfig, + instanceFn = { model -> model.instance() ?: throw IllegalStateException("Model or Provider not set") } + ) { + override fun instance(model: ApiChatModel) = model.instance() ?: throw IllegalStateException("Model or Provider not set") + } + + app.getSettingsFile(session, UserSettingsManager.defaultUser).writeText(orchestrationConfig.toJson()) + SessionProxyServer.chats[session] = app + ApplicationServer.appInfoMap[session] = AppInfoData( + applicationName = "Business Proposal Generation", inputCnt = 0, stickyInput = false, showMenubar = false + ) + SessionProxyServer.metadataStorage.setSessionName( + null, session, "Business Proposal @ ${SimpleDateFormat("HH:mm:ss").format(System.currentTimeMillis())}" + ) + } + + private fun getProjectRoot(e: AnActionEvent): File? { + val folder = e.getSelectedFolder() + return folder?.toFile ?: e.getSelectedFile()?.parent?.toFile?.let { file -> + getModuleRootForFile(file) + } + } + + class BusinessProposalDialog( + project: Project?, + private val root: File, + val relatedFiles: List + ) : DialogWrapper(project) { + + private val proposalTitleField = JBTextField().apply { + toolTipText = "The title or name of the proposal" + text = "Project Proposal" + } + + private val proposalTypeCombo = ComboBox( + arrayOf("project", "investment", "grant", "partnership", "rfp_response") + ).apply { + toolTipText = "The type of proposal" + selectedItem = "project" + } + + private val objectiveArea = JBTextArea(4, 40).apply { + lineWrap = true + wrapStyleWord = true + toolTipText = "The primary objective or goal of the proposal" + } + + private val proposingOrgField = JBTextField().apply { + toolTipText = "The organization or individual submitting the proposal" + } + + private val decisionMakersField = JBTextField().apply { + toolTipText = "Comma-separated list of decision-makers (e.g., 'CEO, CFO, Board of Directors')" + } + + private val budgetRangeField = JBTextField().apply { + toolTipText = "Budget range or financial scope (e.g., '$50,000-$100,000', 'under $1M')" + } + + private val timelineField = JBTextField().apply { + toolTipText = "Project timeline or duration (e.g., '6 months', '2024-2025', 'Q1-Q3')" + } + + private val stakeholdersArea = JBTextArea(3, 40).apply { + lineWrap = true + wrapStyleWord = true + toolTipText = "Key stakeholders and their interests (format: 'Name: Interest' per line)" + } + + private val includeROICheckbox = JBCheckBox("Include ROI Analysis", true).apply { + toolTipText = "Include detailed ROI calculations and financial projections" + } + + private val includeRiskCheckbox = JBCheckBox("Include Risk Assessment", true).apply { + toolTipText = "Include risk assessment and mitigation strategies" + } + + private val includeCompetitiveCheckbox = JBCheckBox("Include Competitive Analysis", true).apply { + toolTipText = "Include competitive analysis or alternatives comparison" + } + + private val includeTimelineCheckbox = JBCheckBox("Include Timeline & Milestones", true).apply { + toolTipText = "Include detailed timeline with milestones" + } + + private val includeResourcesCheckbox = JBCheckBox("Include Resource Requirements", true).apply { + toolTipText = "Include team/resource requirements" + } + + private val includeAppendicesCheckbox = JBCheckBox("Include Appendices", true).apply { + toolTipText = "Include appendices and supporting documents" + } + + private val urgencyCombo = ComboBox( + arrayOf("critical", "high", "moderate", "low") + ).apply { + toolTipText = "Urgency level of the opportunity" + selectedItem = "moderate" + } + + private val toneCombo = ComboBox( + arrayOf("formal", "professional", "persuasive", "collaborative") + ).apply { + toolTipText = "Tone of the proposal" + selectedItem = "professional" + } + + private val targetWordCountSpinner = JSpinner(SpinnerNumberModel(3000, 1000, 10000, 500)).apply { + toolTipText = "Target word count for the complete proposal" + } + + private val revisionPassesSpinner = JSpinner(SpinnerNumberModel(1, 0, 5, 1)).apply { + toolTipText = "Number of revision passes for quality improvement (0-5)" + } + + private val relatedFilesField = JBTextField().apply { + toolTipText = "Comma-separated list of related files to incorporate" + text = relatedFiles.joinToString(", ") { it.relativeTo(root).path } + } + + private val inputFilesField = JBTextField().apply { + toolTipText = "Comma-separated list of input files or patterns (e.g., **/*.kt)" + text = relatedFiles.joinToString(", ") { it.relativeTo(root).path } + } + + private val visibleModelsCache by lazy { getVisibleModels() } + + private val modelCombo = ComboBox( + visibleModelsCache.distinctBy { it.modelName }.map { it.modelName }.toTypedArray() + ).apply { + maximumSize = Dimension(200, 30) + selectedItem = AppSettingsState.instance.smartModel?.model?.modelName + toolTipText = "AI model to use for generating the proposal" + } + + private val temperatureSlider = JSlider(0, 100, 70).apply { + addChangeListener { + temperatureLabel.text = "%.2f".format(value / 100.0) + } + } + + private val temperatureLabel = JLabel("0.70") + + private val autoFixCheckbox = JBCheckBox("Auto-apply generated proposal", false).apply { + toolTipText = "Automatically save the generated proposal without manual confirmation" + } + + init { + init() + title = "Configure Business Proposal Generation" + } + + override fun createCenterPanel(): JComponent = panel { + group("Proposal Information") { + row("Proposal Title:") { + cell(proposalTitleField) + .align(Align.FILL) + .comment("The title or name of the proposal") + } + + row("Proposal Type:") { + cell(proposalTypeCombo) + .align(Align.FILL) + .comment("Type: project, investment, grant, partnership, or RFP response") + } + + row("Objective:") { + scrollCell(objectiveArea) + .align(Align.FILL) + .comment("The primary objective or goal of the proposal") + .resizableColumn() + }.resizableRow() + + row("Proposing Organization:") { + cell(proposingOrgField) + .align(Align.FILL) + .comment("Organization or individual submitting the proposal") + } + } + + group("Stakeholders & Audience") { + row("Decision Makers:") { + cell(decisionMakersField) + .align(Align.FILL) + .comment("Comma-separated list (e.g., 'CEO, CFO, Board of Directors')") + } + + row("Stakeholders:") { + scrollCell(stakeholdersArea) + .align(Align.FILL) + .comment("Key stakeholders and interests (format: 'Name: Interest' per line)") + .resizableColumn() + }.resizableRow() + } + + group("Budget & Timeline") { + row("Budget Range:") { + cell(budgetRangeField) + .align(Align.FILL) + .comment("e.g., '$50,000-$100,000', 'under $1M'") + } + + row("Timeline:") { + cell(timelineField) + .align(Align.FILL) + .comment("e.g., '6 months', '2024-2025', 'Q1-Q3'") + } + } + + group("Analysis Components") { + row { + cell(includeROICheckbox) + } + row { + cell(includeRiskCheckbox) + } + row { + cell(includeCompetitiveCheckbox) + } + row { + cell(includeTimelineCheckbox) + } + row { + cell(includeResourcesCheckbox) + } + row { + cell(includeAppendicesCheckbox) + } + } + + group("Proposal Settings") { + row("Urgency Level:") { + cell(urgencyCombo) + .align(Align.FILL) + .comment("Urgency: critical, high, moderate, or low") + } + + row("Tone:") { + cell(toneCombo) + .align(Align.FILL) + .comment("Tone: formal, professional, persuasive, or collaborative") + } + + row("Target Word Count:") { + cell(targetWordCountSpinner) + .comment("Target word count for the complete proposal (1000-10000)") + } + + row("Revision Passes:") { + cell(revisionPassesSpinner) + .comment("Number of revision passes for quality improvement (0-5)") + } + } + + group("Context Files") { + row("Related Files:") { + cell(relatedFilesField) + .align(Align.FILL) + .comment("Comma-separated list of related files to incorporate") + } + + row("Input Files:") { + cell(inputFilesField) + .align(Align.FILL) + .comment("Comma-separated list of input files or patterns (e.g., **/*.kt)") + } + } + + group("Model Settings") { + row("Model:") { + cell(modelCombo) + .align(Align.FILL) + .comment("AI model for generating the proposal") + } + + row("Temperature:") { + cell(temperatureSlider) + .align(Align.FILL) + .comment("Higher values = more creative, lower = more focused") + cell(temperatureLabel) + } + + row { + cell(autoFixCheckbox) + } + } + } + + override fun doValidate(): com.intellij.openapi.ui.ValidationInfo? { + if (proposalTitleField.text.isBlank()) { + return com.intellij.openapi.ui.ValidationInfo("Proposal title is required", proposalTitleField) + } + + if (objectiveArea.text.isBlank()) { + return com.intellij.openapi.ui.ValidationInfo("Objective is required", objectiveArea) + } + + return null + } + + fun getTaskConfig(): BusinessProposalTask.BusinessProposalTaskExecutionConfigData { + val relatedFiles = relatedFilesField.text.split(",").map { it.trim() }.filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + val inputFiles = inputFilesField.text.split(",").map { it.trim() }.filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + val decisionMakers = decisionMakersField.text.split(",").map { it.trim() }.filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + val stakeholders = stakeholdersArea.text.lines() + .filter { it.contains(":") } + .associate { + val parts = it.split(":", limit = 2) + parts[0].trim() to parts[1].trim() + } + .takeIf { it.isNotEmpty() } + + return BusinessProposalTask.BusinessProposalTaskExecutionConfigData( + proposal_title = proposalTitleField.text, + proposal_type = proposalTypeCombo.selectedItem as String, + objective = objectiveArea.text, + proposing_organization = proposingOrgField.text.takeIf { it.isNotBlank() }, + decision_makers = decisionMakers, + budget_range = budgetRangeField.text.takeIf { it.isNotBlank() }, + timeline = timelineField.text.takeIf { it.isNotBlank() }, + stakeholders = stakeholders, + include_roi_analysis = includeROICheckbox.isSelected, + include_risk_assessment = includeRiskCheckbox.isSelected, + include_competitive_analysis = includeCompetitiveCheckbox.isSelected, + include_timeline_milestones = includeTimelineCheckbox.isSelected, + include_resource_requirements = includeResourcesCheckbox.isSelected, + include_appendices = includeAppendicesCheckbox.isSelected, + urgency_level = urgencyCombo.selectedItem as String, + tone = toneCombo.selectedItem as String, + target_word_count = targetWordCountSpinner.value as Int, + revision_passes = revisionPassesSpinner.value as Int, + related_files = relatedFiles, + input_files = inputFiles, + state = TaskState.Pending + ) + } + + fun getOrchestrationConfig(): OrchestrationConfig { + val selectedModel = modelCombo.selectedItem as? String + val model = selectedModel?.let { modelName -> + visibleModelsCache.find { it.modelName == modelName }?.toApiChatModel() + } + + return OrchestrationConfig( + defaultModel = model ?: AppSettingsState.instance.smartModel + ?: throw IllegalStateException("No model configured"), + parsingModel = AppSettingsState.instance.fastModel + ?: throw IllegalStateException("Fast model not configured"), + temperature = temperatureSlider.value / 100.0, + autoFix = autoFixCheckbox.isSelected, + workingDir = root.absolutePath, + shellCmd = listOf( + if (System.getProperty("os.name").lowercase().contains("win")) "powershell" else "bash" + ) + ) + } + + private fun getVisibleModels() = + ApplicationServices.fileApplicationServices().userSettingsManager.getUserSettings().apis.flatMap { apiData -> + apiData.provider?.getChatModels(apiData.key!!, apiData.baseUrl)?.filter { model -> + model.provider == apiData.provider && + model.modelName?.isNotBlank() == true && + PlanConfigDialog.isVisible(model) + } ?: listOf() + }.distinctBy { it.modelName }.sortedBy { "${it.provider?.name} - ${it.modelName}" } + } +} \ No newline at end of file diff --git a/intellij/src/main/kotlin/cognotik/actions/task/NarrativeGenerationAction.kt b/intellij/src/main/kotlin/cognotik/actions/task/NarrativeGenerationAction.kt new file mode 100644 index 000000000..d647ff10a --- /dev/null +++ b/intellij/src/main/kotlin/cognotik/actions/task/NarrativeGenerationAction.kt @@ -0,0 +1,433 @@ +package cognotik.actions.task + +import cognotik.actions.BaseAction +import cognotik.actions.agent.toFile +import cognotik.actions.plan.PlanConfigDialog +import cognotik.actions.plan.toApiChatModel +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import com.simiacryptus.cognotik.CognotikAppServer +import com.simiacryptus.cognotik.apps.general.SingleTaskApp +import com.simiacryptus.cognotik.config.AppSettingsState +import com.simiacryptus.cognotik.config.instance +import com.simiacryptus.cognotik.plan.AbstractTask.TaskState +import com.simiacryptus.cognotik.plan.OrchestrationConfig +import com.simiacryptus.cognotik.plan.tools.writing.NarrativeGenerationTask +import com.simiacryptus.cognotik.platform.ApplicationServices +import com.simiacryptus.cognotik.platform.Session +import com.simiacryptus.cognotik.platform.file.DataStorage +import com.simiacryptus.cognotik.platform.file.UserSettingsManager +import com.simiacryptus.cognotik.platform.model.ApiChatModel +import com.simiacryptus.cognotik.util.* +import com.simiacryptus.cognotik.util.BrowseUtil.browse +import com.simiacryptus.cognotik.webui.application.AppInfoData +import com.simiacryptus.cognotik.webui.application.ApplicationServer +import java.awt.Dimension +import java.io.File +import java.text.SimpleDateFormat +import javax.swing.JComponent +import javax.swing.JSlider +import javax.swing.JSpinner +import javax.swing.SpinnerNumberModel + +class NarrativeGenerationAction : BaseAction() { + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + override fun isEnabled(event: AnActionEvent): Boolean { + if (!super.isEnabled(event)) return false + if (event.getSelectedFiles().isEmpty() && event.getSelectedFolder() == null) return false + return true + } + + override fun handle(e: AnActionEvent) { + val root = getProjectRoot(e) ?: return + val relatedFiles = getFiles(e) + val dialog = NarrativeGenerationDialog( + e.project, root, relatedFiles + ) + + if (dialog.showAndGet()) { + try { + val taskConfig = dialog.getTaskConfig() + val orchestrationConfig = dialog.getOrchestrationConfig() + + UITools.runAsync(e.project, "Initializing Narrative Generation Task", true) { progress -> + initializeTask(e, progress, orchestrationConfig, taskConfig, root) + } + } catch (ex: Exception) { + log.error("Failed to initialize narrative generation task", ex) + UITools.showError(e.project, "Failed to initialize task: ${ex.message}") + } + } + } + + private fun initializeTask( + e: AnActionEvent, + progress: ProgressIndicator, + orchestrationConfig: OrchestrationConfig, + taskConfig: NarrativeGenerationTask.NarrativeGenerationTaskExecutionConfigData, + root: File + ) { + progress.text = "Setting up session..." + val session = Session.newGlobalID() + + DataStorage.sessionPaths[session] = root + + progress.text = "Starting server..." + setupTaskSession(session, orchestrationConfig, taskConfig, root) + + Thread { + Thread.sleep(500) + try { + val uri = CognotikAppServer.getServer().server.uri.resolve("/#$session") + log.info("Opening browser to $uri") + browse(uri) + } catch (e: Throwable) { + log.warn("Error opening browser", e) + } + }.start() + } + + private fun setupTaskSession( + session: Session, + orchestrationConfig: OrchestrationConfig, + taskConfig: NarrativeGenerationTask.NarrativeGenerationTaskExecutionConfigData, + root: File + ) { + val app = object : SingleTaskApp( + applicationName = "Narrative Generation Task", + path = "/narrativeGenerationTask", + showMenubar = false, + taskType = NarrativeGenerationTask.NarrativeGeneration, + taskConfig = taskConfig, + instanceFn = { model -> model.instance() ?: throw IllegalStateException("Model or Provider not set") } + ) { + override fun instance(model: ApiChatModel) = model.instance() ?: throw IllegalStateException("Model or Provider not set") + } + + app.getSettingsFile(session, UserSettingsManager.defaultUser).writeText(orchestrationConfig.toJson()) + SessionProxyServer.chats[session] = app + ApplicationServer.appInfoMap[session] = AppInfoData( + applicationName = "Narrative Generation Task", inputCnt = 0, stickyInput = false, showMenubar = false + ) + SessionProxyServer.metadataStorage.setSessionName( + null, session, "Narrative Generation @ ${SimpleDateFormat("HH:mm:ss").format(System.currentTimeMillis())}" + ) + } + + private fun getProjectRoot(e: AnActionEvent): File? { + val folder = e.getSelectedFolder() + return folder?.toFile ?: e.getSelectedFile()?.parent?.toFile?.let { file -> + getModuleRootForFile(file) + } + } + + class NarrativeGenerationDialog( + project: Project?, + private val root: File, + val relatedFiles: List + ) : DialogWrapper(project) { + + private val subjectField = JBTextField().apply { + toolTipText = "The subject or scenario to develop into a full narrative" + text = "A compelling story" + } + + private val inputFilesField = JBTextField().apply { + toolTipText = "Comma-separated file patterns for context (e.g., **/*.kt, docs/*.md)" + text = relatedFiles.joinToString(", ") { it.relativeTo(root).path } + } + + private val targetWordCountSpinner = JSpinner(SpinnerNumberModel(5000, 500, 50000, 500)).apply { + toolTipText = "Target word count for the complete narrative" + } + + private val numberOfActsSpinner = JSpinner(SpinnerNumberModel(3, 1, 10, 1)).apply { + toolTipText = "Number of acts in the narrative structure (typically 3 or 5)" + } + + private val scenesPerActSpinner = JSpinner(SpinnerNumberModel(3, 1, 10, 1)).apply { + toolTipText = "Average number of scenes per act" + } + + private val writingStyleCombo = ComboBox( + arrayOf("literary", "thriller", "technical", "conversational", "academic", "journalistic") + ).apply { + selectedItem = "literary" + toolTipText = "Writing style for the narrative" + } + + private val pointOfViewCombo = ComboBox( + arrayOf("first person", "third person limited", "third person omniscient", "second person") + ).apply { + selectedItem = "third person limited" + toolTipText = "Point of view for the narrative" + } + + private val toneCombo = ComboBox( + arrayOf("dramatic", "humorous", "suspenseful", "reflective", "inspirational", "dark") + ).apply { + selectedItem = "dramatic" + toolTipText = "Overall tone of the narrative" + } + + private val detailedDescriptionsCheckbox = JBCheckBox("Include detailed scene descriptions", true).apply { + toolTipText = "Whether to include vivid, sensory descriptions" + } + + private val includeDialogueCheckbox = JBCheckBox("Include character dialogue", true).apply { + toolTipText = "Whether to include natural dialogue between characters" + } + + private val showInternalThoughtsCheckbox = JBCheckBox("Show internal character thoughts", true).apply { + toolTipText = "Whether to reveal character internal thoughts and feelings" + } + + private val revisionPassesSpinner = JSpinner(SpinnerNumberModel(1, 0, 5, 1)).apply { + toolTipText = "Number of revision passes for each scene (0 = no revisions)" + } + + private val generateSceneImagesCheckbox = JBCheckBox("Generate images for each scene", false).apply { + toolTipText = "Use AI to generate visualization images for each scene" + } + + private val generateCoverImageCheckbox = JBCheckBox("Generate cover image", false).apply { + toolTipText = "Use AI to generate a cover image for the narrative" + } + + private val narrativeElementsArea = JBTextArea(4, 40).apply { + lineWrap = true + wrapStyleWord = true + toolTipText = "Optional: Define narrative elements as key:value pairs (one per line)\nExample:\nprotagonist: John Smith\nsetting: Victorian London\nconflict: Man vs Society" + } + + private val visibleModelsCache by lazy { getVisibleModels() } + + private val modelCombo = ComboBox( + visibleModelsCache.distinctBy { it.modelName }.map { it.modelName }.toTypedArray() + ).apply { + maximumSize = Dimension(200, 30) + selectedItem = AppSettingsState.instance.smartModel?.model?.modelName + toolTipText = "AI model to use for narrative generation" + } + + private val imageModelCombo = ComboBox( + visibleModelsCache + .distinctBy { it.modelName } + .map { it.modelName } + .toTypedArray() + ).apply { + maximumSize = Dimension(200, 30) + selectedItem = AppSettingsState.instance.imageChatModel?.model?.modelName + toolTipText = "AI model to use for generating images" + } + + private val temperatureSlider = JSlider(0, 100, 80).apply { + addChangeListener { + temperatureLabel.text = "%.2f".format(value / 100.0) + } + } + + private val temperatureLabel = javax.swing.JLabel("0.80") + + init { + init() + title = "Configure Narrative Generation Task" + } + + override fun createCenterPanel(): JComponent = panel { + group("Narrative Configuration") { + row("Subject:") { + cell(subjectField) + .align(Align.FILL) + .comment("The subject or scenario to develop into a full narrative") + } + + row("Input Files:") { + cell(inputFilesField) + .align(Align.FILL) + .comment("File patterns for context (optional, e.g., **/*.kt, docs/*.md)") + } + + row("Narrative Elements:") { + scrollCell(narrativeElementsArea) + .align(Align.FILL) + .comment("Optional: Define characters, setting, conflict, etc. (key:value pairs, one per line)") + .resizableColumn() + }.resizableRow() + } + + group("Structure") { + row("Target Word Count:") { + cell(targetWordCountSpinner) + .comment("Total words for the complete narrative") + } + + row("Number of Acts:") { + cell(numberOfActsSpinner) + .comment("Narrative structure (typically 3 or 5 acts)") + } + + row("Scenes per Act:") { + cell(scenesPerActSpinner) + .comment("Average scenes in each act") + } + } + + group("Writing Style") { + row("Style:") { + cell(writingStyleCombo) + .align(Align.FILL) + .comment("Overall writing style") + } + + row("Point of View:") { + cell(pointOfViewCombo) + .align(Align.FILL) + .comment("Narrative perspective") + } + + row("Tone:") { + cell(toneCombo) + .align(Align.FILL) + .comment("Emotional tone of the narrative") + } + + row { + cell(detailedDescriptionsCheckbox) + } + + row { + cell(includeDialogueCheckbox) + } + + row { + cell(showInternalThoughtsCheckbox) + } + } + + group("Quality & Images") { + row("Revision Passes:") { + cell(revisionPassesSpinner) + .comment("Number of editing passes per scene (0 = no revisions)") + } + + row { + cell(generateSceneImagesCheckbox) + } + + row { + cell(generateCoverImageCheckbox) + } + + row("Image Model:") { + cell(imageModelCombo) + .align(Align.FILL) + .comment("AI model for image generation") + } + } + + group("Model Settings") { + row("Text Model:") { + cell(modelCombo) + .align(Align.FILL) + .comment("AI model for narrative generation") + } + + row("Temperature:") { + cell(temperatureSlider) + .align(Align.FILL) + .comment("Higher values = more creative, lower = more focused") + cell(temperatureLabel) + } + } + } + + override fun doValidate(): com.intellij.openapi.ui.ValidationInfo? { + if (subjectField.text.isBlank()) { + return com.intellij.openapi.ui.ValidationInfo("Subject is required", subjectField) + } + + return null + } + + fun getTaskConfig(): NarrativeGenerationTask.NarrativeGenerationTaskExecutionConfigData { + val inputFiles = inputFilesField.text.split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + val narrativeElements = narrativeElementsArea.text.lines() + .filter { it.contains(":") } + .associate { + val (key, value) = it.split(":", limit = 2) + key.trim() to value.trim() + } + .takeIf { it.isNotEmpty() } + + return NarrativeGenerationTask.NarrativeGenerationTaskExecutionConfigData( + subject = subjectField.text, + input_files = inputFiles, + narrative_elements = narrativeElements, + target_word_count = targetWordCountSpinner.value as Int, + number_of_acts = numberOfActsSpinner.value as Int, + scenes_per_act = scenesPerActSpinner.value as Int, + writing_style = writingStyleCombo.selectedItem as String, + point_of_view = pointOfViewCombo.selectedItem as String, + tone = toneCombo.selectedItem as String, + detailed_descriptions = detailedDescriptionsCheckbox.isSelected, + include_dialogue = includeDialogueCheckbox.isSelected, + show_internal_thoughts = showInternalThoughtsCheckbox.isSelected, + revision_passes = revisionPassesSpinner.value as Int, + generate_scene_images = generateSceneImagesCheckbox.isSelected, + generate_cover_image = generateCoverImageCheckbox.isSelected, + state = TaskState.Pending + ) + } + + fun getOrchestrationConfig(): OrchestrationConfig { + val selectedModel = modelCombo.selectedItem as? String + val model = selectedModel?.let { modelName -> + visibleModelsCache.find { it.modelName == modelName }?.toApiChatModel() + } + + val selectedImageModel = imageModelCombo.selectedItem as? String + val imageModel = selectedImageModel?.let { modelName -> + visibleModelsCache.find { it.modelName == modelName }?.toApiChatModel() + } + + return OrchestrationConfig( + defaultModel = model ?: AppSettingsState.instance.smartModel + ?: throw IllegalStateException("No model configured"), + parsingModel = AppSettingsState.instance.fastModel + ?: throw IllegalStateException("Fast model not configured"), + imageChatModel = imageModel ?: AppSettingsState.instance.imageChatModel + ?: throw IllegalStateException("No image model configured"), + temperature = temperatureSlider.value / 100.0, + autoFix = false, + workingDir = root.absolutePath, + shellCmd = listOf( + if (System.getProperty("os.name").lowercase().contains("win")) "powershell" else "bash" + ) + ) + } + + private fun getVisibleModels() = + ApplicationServices.fileApplicationServices().userSettingsManager.getUserSettings().apis.flatMap { apiData -> + apiData.provider?.getChatModels(apiData.key!!, apiData.baseUrl)?.filter { model -> + model.provider == apiData.provider && + model.modelName?.isNotBlank() == true && + PlanConfigDialog.isVisible(model) + } ?: listOf() + }.distinctBy { it.modelName }.sortedBy { "${it.provider?.name} - ${it.modelName}" } + } +} \ No newline at end of file diff --git a/intellij/src/main/kotlin/cognotik/actions/task/PersuasiveEssayAction.kt b/intellij/src/main/kotlin/cognotik/actions/task/PersuasiveEssayAction.kt new file mode 100644 index 000000000..000419c8f --- /dev/null +++ b/intellij/src/main/kotlin/cognotik/actions/task/PersuasiveEssayAction.kt @@ -0,0 +1,393 @@ +package cognotik.actions.task + +import cognotik.actions.BaseAction +import cognotik.actions.agent.toFile +import cognotik.actions.plan.PlanConfigDialog +import cognotik.actions.plan.toApiChatModel +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import com.simiacryptus.cognotik.CognotikAppServer +import com.simiacryptus.cognotik.apps.general.SingleTaskApp +import com.simiacryptus.cognotik.config.AppSettingsState +import com.simiacryptus.cognotik.config.instance +import com.simiacryptus.cognotik.plan.AbstractTask.TaskState +import com.simiacryptus.cognotik.plan.OrchestrationConfig +import com.simiacryptus.cognotik.plan.tools.writing.PersuasiveEssayTask +import com.simiacryptus.cognotik.platform.ApplicationServices +import com.simiacryptus.cognotik.platform.Session +import com.simiacryptus.cognotik.platform.file.DataStorage +import com.simiacryptus.cognotik.platform.file.UserSettingsManager +import com.simiacryptus.cognotik.platform.model.ApiChatModel +import com.simiacryptus.cognotik.util.* +import com.simiacryptus.cognotik.util.BrowseUtil.browse +import com.simiacryptus.cognotik.webui.application.AppInfoData +import com.simiacryptus.cognotik.webui.application.ApplicationServer +import java.awt.Dimension +import java.io.File +import java.text.SimpleDateFormat +import javax.swing.JComponent +import javax.swing.JSlider +import javax.swing.JSpinner +import javax.swing.SpinnerNumberModel + +class PersuasiveEssayAction : BaseAction() { + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + override fun isEnabled(event: AnActionEvent): Boolean { + if (!super.isEnabled(event)) return false + if (event.getSelectedFiles().isEmpty() && event.getSelectedFolder() == null) return false + return true + } + + override fun handle(e: AnActionEvent) { + val root = getProjectRoot(e) ?: return + val relatedFiles = getFiles(e) + val dialog = PersuasiveEssayTaskDialog( + e.project, root, relatedFiles + ) + + if (dialog.showAndGet()) { + try { + val taskConfig = dialog.getTaskConfig() + val orchestrationConfig = dialog.getOrchestrationConfig() + + UITools.runAsync(e.project, "Initializing Persuasive Essay Task", true) { progress -> + initializeTask(e, progress, orchestrationConfig, taskConfig, root) + } + } catch (ex: Exception) { + log.error("Failed to initialize persuasive essay task", ex) + UITools.showError(e.project, "Failed to initialize task: ${ex.message}") + } + } + } + + private fun initializeTask( + e: AnActionEvent, + progress: ProgressIndicator, + orchestrationConfig: OrchestrationConfig, + taskConfig: PersuasiveEssayTask.PersuasiveEssayTaskExecutionConfigData, + root: File + ) { + progress.text = "Setting up session..." + val session = Session.newGlobalID() + + DataStorage.sessionPaths[session] = root + + progress.text = "Starting server..." + setupTaskSession(session, orchestrationConfig, taskConfig, root) + + Thread { + Thread.sleep(500) + try { + val uri = CognotikAppServer.getServer().server.uri.resolve("/#$session") + log.info("Opening browser to $uri") + browse(uri) + } catch (e: Throwable) { + log.warn("Error opening browser", e) + } + }.start() + } + + private fun setupTaskSession( + session: Session, orchestrationConfig: OrchestrationConfig, taskConfig: PersuasiveEssayTask.PersuasiveEssayTaskExecutionConfigData, root: File + ) { + val app = object : SingleTaskApp( + applicationName = "Persuasive Essay Task", + path = "/persuasiveEssayTask", + showMenubar = false, + taskType = PersuasiveEssayTask.PersuasiveEssay, + taskConfig = taskConfig, + instanceFn = { model -> model.instance() ?: throw IllegalStateException("Model or Provider not set") } + ) { + override fun instance(model: ApiChatModel) = model.instance() ?: throw IllegalStateException("Model or Provider not set") + } + + app.getSettingsFile(session, UserSettingsManager.defaultUser).writeText(orchestrationConfig.toJson()) + SessionProxyServer.chats[session] = app + ApplicationServer.appInfoMap[session] = AppInfoData( + applicationName = "Persuasive Essay Task", inputCnt = 0, stickyInput = false, showMenubar = false + ) + SessionProxyServer.metadataStorage.setSessionName( + null, session, "Persuasive Essay @ ${SimpleDateFormat("HH:mm:ss").format(System.currentTimeMillis())}" + ) + } + + private fun getProjectRoot(e: AnActionEvent): File? { + val folder = e.getSelectedFolder() + return folder?.toFile ?: e.getSelectedFile()?.parent?.toFile?.let { file -> + getModuleRootForFile(file) + } + } + + class PersuasiveEssayTaskDialog( + project: Project?, + private val root: File, + val relatedFiles: List + ) : DialogWrapper(project) { + + private val thesisArea = JBTextArea(4, 40).apply { + lineWrap = true + wrapStyleWord = true + toolTipText = "Enter the thesis statement or position you want to argue for" + } + + private val targetAudienceField = JBTextField().apply { + toolTipText = "Target audience (e.g., 'general public', 'academics', 'policymakers', 'business leaders')" + text = "general public" + } + + private val toneField = JBTextField().apply { + toolTipText = "Tone of the essay (e.g., 'formal', 'conversational', 'passionate', 'analytical')" + text = "formal" + } + + private val wordCountSpinner = JSpinner(SpinnerNumberModel(1500, 500, 5000, 100)).apply { + toolTipText = "Target word count for the complete essay (500-5000)" + } + + private val numArgumentsSpinner = JSpinner(SpinnerNumberModel(3, 1, 10, 1)).apply { + toolTipText = "Number of main arguments to develop (1-10)" + } + + private val includeCounterargumentsCheckbox = JBCheckBox("Include counterarguments and rebuttals", true).apply { + toolTipText = "Address opposing viewpoints and provide rebuttals" + } + + private val useRhetoricalDevicesCheckbox = JBCheckBox("Use rhetorical devices (ethos, pathos, logos)", true).apply { + toolTipText = "Employ classical rhetorical techniques for persuasive impact" + } + + private val includeEvidenceCheckbox = JBCheckBox("Include statistical evidence and citations", true).apply { + toolTipText = "Use data, statistics, and expert testimony" + } + + private val useAnalogiesCheckbox = JBCheckBox("Use analogies and examples", true).apply { + toolTipText = "Include concrete examples and analogies for clarity" + } + + private val callToActionCombo = ComboBox(arrayOf("strong", "moderate", "reflective", "none")).apply { + selectedItem = "strong" + toolTipText = "Type of call to action in the conclusion" + } + + private val revisionPassesSpinner = JSpinner(SpinnerNumberModel(1, 0, 5, 1)).apply { + toolTipText = "Number of revision passes for quality improvement (0-5)" + } + + private val inputFilesField = JBTextField().apply { + toolTipText = "Comma-separated list of input files or patterns (e.g., research/*.md, **/*.txt)" + text = relatedFiles.joinToString(", ") { it.relativeTo(root).path } + } + + private val relatedFilesField = JBTextField().apply { + toolTipText = "Additional related files for context (optional)" + text = "" + } + + private val visibleModelsCache by lazy { getVisibleModels() } + + private val modelCombo = ComboBox( + visibleModelsCache.distinctBy { it.modelName }.map { it.modelName }.toTypedArray() + ).apply { + maximumSize = Dimension(200, 30) + selectedItem = AppSettingsState.instance.smartModel?.model?.modelName + toolTipText = "AI model to use for generating the essay" + } + + private val temperatureSlider = JSlider(0, 100, 70).apply { + addChangeListener { + temperatureLabel.text = "%.2f".format(value / 100.0) + } + } + + private val temperatureLabel = javax.swing.JLabel("0.70") + + private val autoFixCheckbox = JBCheckBox("Auto-apply generated essay", false).apply { + toolTipText = "Automatically save the generated essay without manual confirmation" + } + + init { + init() + title = "Configure Persuasive Essay Task" + } + + override fun createCenterPanel(): JComponent = panel { + group("Essay Configuration") { + row("Thesis Statement:") { + scrollCell(thesisArea) + .align(Align.FILL) + .comment("The main position or argument you want to defend") + .resizableColumn() + }.resizableRow() + + row("Target Audience:") { + cell(targetAudienceField) + .align(Align.FILL) + .comment("Who you're writing for (affects tone and approach)") + } + + row("Tone:") { + cell(toneField) + .align(Align.FILL) + .comment("Overall tone of the essay") + } + + row("Target Word Count:") { + cell(wordCountSpinner) + .comment("Approximate length of the complete essay") + } + + row("Number of Arguments:") { + cell(numArgumentsSpinner) + .comment("How many main arguments to develop") + } + } + + group("Persuasive Techniques") { + row { + cell(includeCounterargumentsCheckbox) + } + + row { + cell(useRhetoricalDevicesCheckbox) + } + + row { + cell(includeEvidenceCheckbox) + } + + row { + cell(useAnalogiesCheckbox) + } + + row("Call to Action:") { + cell(callToActionCombo) + .comment("Type of conclusion and call to action") + } + } + + group("Input Files") { + row("Input Files:") { + cell(inputFilesField) + .align(Align.FILL) + .comment("Research files to incorporate (supports glob patterns)") + } + + row("Related Files:") { + cell(relatedFilesField) + .align(Align.FILL) + .comment("Additional context files (optional)") + } + } + + group("Model Settings") { + row("Model:") { + cell(modelCombo) + .align(Align.FILL) + .comment("AI model for generating the essay") + } + + row("Temperature:") { + cell(temperatureSlider) + .align(Align.FILL) + .comment("Higher values = more creative, lower = more focused") + cell(temperatureLabel) + } + + row("Revision Passes:") { + cell(revisionPassesSpinner) + .comment("Number of quality improvement passes") + } + + row { + cell(autoFixCheckbox) + } + } + } + + override fun doValidate(): com.intellij.openapi.ui.ValidationInfo? { + if (thesisArea.text.isBlank()) { + return com.intellij.openapi.ui.ValidationInfo("Thesis statement is required", thesisArea) + } + + if (targetAudienceField.text.isBlank()) { + return com.intellij.openapi.ui.ValidationInfo("Target audience is required", targetAudienceField) + } + + if (toneField.text.isBlank()) { + return com.intellij.openapi.ui.ValidationInfo("Tone is required", toneField) + } + + val validCallToActions = setOf("strong", "moderate", "reflective", "none") + if (callToActionCombo.selectedItem.toString().lowercase() !in validCallToActions) { + return com.intellij.openapi.ui.ValidationInfo("Invalid call to action type", callToActionCombo) + } + + return null + } + + fun getTaskConfig(): PersuasiveEssayTask.PersuasiveEssayTaskExecutionConfigData { + val inputFiles = inputFilesField.text.split(",").map { it.trim() }.filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + val relatedFiles = relatedFilesField.text.split(",").map { it.trim() }.filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + return PersuasiveEssayTask.PersuasiveEssayTaskExecutionConfigData( + thesis = thesisArea.text, + target_audience = targetAudienceField.text, + tone = toneField.text, + target_word_count = wordCountSpinner.value as Int, + num_arguments = numArgumentsSpinner.value as Int, + include_counterarguments = includeCounterargumentsCheckbox.isSelected, + use_rhetorical_devices = useRhetoricalDevicesCheckbox.isSelected, + include_evidence = includeEvidenceCheckbox.isSelected, + use_analogies = useAnalogiesCheckbox.isSelected, + call_to_action = callToActionCombo.selectedItem.toString(), + revision_passes = revisionPassesSpinner.value as Int, + input_files = inputFiles, + related_files = relatedFiles, + state = TaskState.Pending + ) + } + + fun getOrchestrationConfig(): OrchestrationConfig { + val selectedModel = modelCombo.selectedItem as? String + val model = selectedModel?.let { modelName -> + visibleModelsCache.find { it.modelName == modelName }?.toApiChatModel() + } + + return OrchestrationConfig( + defaultModel = model ?: AppSettingsState.instance.smartModel + ?: throw IllegalStateException("No model configured"), + parsingModel = AppSettingsState.instance.fastModel + ?: throw IllegalStateException("Fast model not configured"), + temperature = temperatureSlider.value / 100.0, + autoFix = autoFixCheckbox.isSelected, + workingDir = root.absolutePath, + shellCmd = listOf( + if (System.getProperty("os.name").lowercase().contains("win")) "powershell" else "bash" + ) + ) + } + + private fun getVisibleModels() = + ApplicationServices.fileApplicationServices().userSettingsManager.getUserSettings().apis.flatMap { apiData -> + apiData.provider?.getChatModels(apiData.key!!, apiData.baseUrl)?.filter { model -> + model.provider == apiData.provider && + model.modelName?.isNotBlank() == true && + PlanConfigDialog.isVisible(model) + } ?: listOf() + }.distinctBy { it.modelName }.sortedBy { "${it.provider?.name} - ${it.modelName}" } + } + +} \ No newline at end of file diff --git a/intellij/src/main/kotlin/cognotik/actions/task/ResearchPaperAction.kt b/intellij/src/main/kotlin/cognotik/actions/task/ResearchPaperAction.kt new file mode 100644 index 000000000..dad158432 --- /dev/null +++ b/intellij/src/main/kotlin/cognotik/actions/task/ResearchPaperAction.kt @@ -0,0 +1,414 @@ +package cognotik.actions.task + +import cognotik.actions.BaseAction +import cognotik.actions.agent.toFile +import cognotik.actions.plan.PlanConfigDialog +import cognotik.actions.plan.toApiChatModel +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import com.simiacryptus.cognotik.CognotikAppServer +import com.simiacryptus.cognotik.apps.general.SingleTaskApp +import com.simiacryptus.cognotik.config.AppSettingsState +import com.simiacryptus.cognotik.config.instance +import com.simiacryptus.cognotik.plan.AbstractTask.TaskState +import com.simiacryptus.cognotik.plan.OrchestrationConfig +import com.simiacryptus.cognotik.plan.tools.writing.ResearchPaperGenerationTask +import com.simiacryptus.cognotik.platform.ApplicationServices +import com.simiacryptus.cognotik.platform.Session +import com.simiacryptus.cognotik.platform.file.DataStorage +import com.simiacryptus.cognotik.platform.file.UserSettingsManager +import com.simiacryptus.cognotik.platform.model.ApiChatModel +import com.simiacryptus.cognotik.util.* +import com.simiacryptus.cognotik.util.BrowseUtil.browse +import com.simiacryptus.cognotik.webui.application.AppInfoData +import com.simiacryptus.cognotik.webui.application.ApplicationServer +import java.awt.Dimension +import java.io.File +import java.text.SimpleDateFormat +import javax.swing.JComponent +import javax.swing.JSlider +import javax.swing.JSpinner +import javax.swing.SpinnerNumberModel + +class ResearchPaperAction : BaseAction() { + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + override fun isEnabled(event: AnActionEvent): Boolean { + if (!super.isEnabled(event)) return false + if (event.getSelectedFiles().isEmpty() && event.getSelectedFolder() == null) return false + return true + } + + override fun handle(e: AnActionEvent) { + val root = getProjectRoot(e) ?: return + val relatedFiles = getFiles(e) + val dialog = ResearchPaperTaskDialog( + e.project, root, relatedFiles + ) + + if (dialog.showAndGet()) { + try { + val taskConfig = dialog.getTaskConfig() + val orchestrationConfig = dialog.getOrchestrationConfig() + + UITools.runAsync(e.project, "Initializing Research Paper Generation Task", true) { progress -> + initializeTask(e, progress, orchestrationConfig, taskConfig, root) + } + } catch (ex: Exception) { + log.error("Failed to initialize research paper generation task", ex) + UITools.showError(e.project, "Failed to initialize task: ${ex.message}") + } + } + } + + private fun initializeTask( + e: AnActionEvent, + progress: ProgressIndicator, + orchestrationConfig: OrchestrationConfig, + taskConfig: ResearchPaperGenerationTask.ResearchPaperGenerationTaskExecutionConfigData, + root: File + ) { + progress.text = "Setting up session..." + val session = Session.newGlobalID() + + DataStorage.sessionPaths[session] = root + + progress.text = "Starting server..." + setupTaskSession(session, orchestrationConfig, taskConfig, root) + + Thread { + Thread.sleep(500) + try { + val uri = CognotikAppServer.getServer().server.uri.resolve("/#$session") + log.info("Opening browser to $uri") + browse(uri) + } catch (e: Throwable) { + log.warn("Error opening browser", e) + } + }.start() + } + + private fun setupTaskSession( + session: Session, + orchestrationConfig: OrchestrationConfig, + taskConfig: ResearchPaperGenerationTask.ResearchPaperGenerationTaskExecutionConfigData, + root: File + ) { + val app = object : SingleTaskApp( + applicationName = "Research Paper Generation Task", + path = "/researchPaperTask", + showMenubar = false, + taskType = ResearchPaperGenerationTask.ResearchPaperGeneration, + taskConfig = taskConfig, + instanceFn = { model -> model.instance() ?: throw IllegalStateException("Model or Provider not set") } + ) { + override fun instance(model: ApiChatModel) = model.instance() ?: throw IllegalStateException("Model or Provider not set") + } + + app.getSettingsFile(session, UserSettingsManager.defaultUser).writeText(orchestrationConfig.toJson()) + SessionProxyServer.chats[session] = app + ApplicationServer.appInfoMap[session] = AppInfoData( + applicationName = "Research Paper Generation Task", inputCnt = 0, stickyInput = false, showMenubar = false + ) + SessionProxyServer.metadataStorage.setSessionName( + null, session, "Research Paper @ ${SimpleDateFormat("HH:mm:ss").format(System.currentTimeMillis())}" + ) + } + + private fun getProjectRoot(e: AnActionEvent): File? { + val folder = e.getSelectedFolder() + return folder?.toFile ?: e.getSelectedFile()?.parent?.toFile?.let { file -> + getModuleRootForFile(file) + } + } + + class ResearchPaperTaskDialog( + project: Project?, + private val root: File, + val relatedFiles: List + ) : DialogWrapper(project) { + + private val researchTopicArea = JBTextArea(4, 40).apply { + lineWrap = true + wrapStyleWord = true + toolTipText = "Describe the main research question or topic for the paper" + } + + private val paperTypeCombo = ComboBox( + arrayOf("empirical", "theoretical", "review", "meta-analysis", "systematic-review") + ).apply { + selectedItem = "empirical" + toolTipText = "Type of research paper to generate" + } + + private val academicLevelCombo = ComboBox( + arrayOf("undergraduate", "masters", "phd", "postdoc") + ).apply { + selectedItem = "masters" + toolTipText = "Academic level for the paper's complexity and rigor" + } + + private val citationStyleCombo = ComboBox( + arrayOf("apa", "mla", "chicago", "ieee") + ).apply { + selectedItem = "apa" + toolTipText = "Citation style to use throughout the paper" + } + + private val targetWordCountSpinner = JSpinner(SpinnerNumberModel(8000, 1000, 50000, 1000)).apply { + toolTipText = "Target word count for the complete paper (1000-50000)" + } + + private val numberOfSectionsSpinner = JSpinner(SpinnerNumberModel(6, 3, 15, 1)).apply { + toolTipText = "Number of main sections (3-15)" + } + + private val revisionPassesSpinner = JSpinner(SpinnerNumberModel(1, 0, 5, 1)).apply { + toolTipText = "Number of revision passes for quality improvement (0-5)" + } + + private val includeLiteratureReviewCheckbox = JBCheckBox("Include Literature Review", true).apply { + toolTipText = "Include a comprehensive literature review section" + } + + private val includeMethodologyCheckbox = JBCheckBox("Include Methodology", true).apply { + toolTipText = "Include a methodology section describing research methods" + } + + private val includeStatisticalAnalysisCheckbox = JBCheckBox("Include Statistical Analysis", true).apply { + toolTipText = "Include descriptions of statistical analysis methods" + } + + private val includePeerReviewCheckbox = JBCheckBox("Include Peer Review Simulation", true).apply { + toolTipText = "Simulate peer review to identify weaknesses and improvements" + } + + private val inputFilesField = JBTextField().apply { + toolTipText = "Comma-separated list of input files or patterns (e.g., **/*.kt, docs/*.md)" + text = relatedFiles.joinToString(", ") { it.relativeTo(root).path } + } + + private val researchFilesField = JBTextField().apply { + toolTipText = "Comma-separated list of research source files to incorporate" + text = "" + } + + private val visibleModelsCache by lazy { getVisibleModels() } + + private val modelCombo = ComboBox( + visibleModelsCache.distinctBy { it.modelName }.map { it.modelName }.toTypedArray() + ).apply { + maximumSize = Dimension(200, 30) + selectedItem = AppSettingsState.instance.smartModel?.model?.modelName + toolTipText = "AI model to use for generating the research paper" + } + + private val temperatureSlider = JSlider(0, 100, 70).apply { + addChangeListener { + temperatureLabel.text = "%.2f".format(value / 100.0) + } + } + + private val temperatureLabel = javax.swing.JLabel("0.70") + + private val autoFixCheckbox = JBCheckBox("Auto-apply generated content", false).apply { + toolTipText = "Automatically apply generated content without manual confirmation" + } + + init { + init() + title = "Configure Research Paper Generation Task" + } + + override fun createCenterPanel(): JComponent = panel { + group("Research Configuration") { + row("Research Topic:") { + scrollCell(researchTopicArea) + .align(Align.FILL) + .comment("Describe the main research question or topic") + .resizableColumn() + }.resizableRow() + + row("Paper Type:") { + cell(paperTypeCombo) + .align(Align.FILL) + .comment("Type of research paper (empirical, theoretical, review, etc.)") + } + + row("Academic Level:") { + cell(academicLevelCombo) + .align(Align.FILL) + .comment("Academic level for complexity and rigor") + } + + row("Citation Style:") { + cell(citationStyleCombo) + .align(Align.FILL) + .comment("Citation format (APA, MLA, Chicago, IEEE)") + } + + row("Target Word Count:") { + cell(targetWordCountSpinner) + .comment("Target word count for the complete paper") + } + + row("Number of Sections:") { + cell(numberOfSectionsSpinner) + .comment("Number of main sections (excluding abstract/conclusion)") + } + + row("Revision Passes:") { + cell(revisionPassesSpinner) + .comment("Number of revision passes for quality improvement") + } + } + + group("Paper Features") { + row { + cell(includeLiteratureReviewCheckbox) + } + + row { + cell(includeMethodologyCheckbox) + } + + row { + cell(includeStatisticalAnalysisCheckbox) + } + + row { + cell(includePeerReviewCheckbox) + } + } + + group("Source Files") { + row("Input Files:") { + cell(inputFilesField) + .align(Align.FILL) + .comment("Files or patterns to use as input (e.g., **/*.kt, docs/*.md)") + } + + row("Research Files:") { + cell(researchFilesField) + .align(Align.FILL) + .comment("Research source files to incorporate (optional)") + } + } + + group("Model Settings") { + row("Model:") { + cell(modelCombo) + .align(Align.FILL) + .comment("AI model for generating the research paper") + } + + row("Temperature:") { + cell(temperatureSlider) + .align(Align.FILL) + .comment("Higher values = more creative, lower = more focused") + cell(temperatureLabel) + } + + row { + cell(autoFixCheckbox) + } + } + } + + override fun doValidate(): com.intellij.openapi.ui.ValidationInfo? { + if (researchTopicArea.text.isBlank()) { + return com.intellij.openapi.ui.ValidationInfo("Research topic is required", researchTopicArea) + } + + val targetWordCount = targetWordCountSpinner.value as Int + if (targetWordCount < 1000 || targetWordCount > 50000) { + return com.intellij.openapi.ui.ValidationInfo( + "Target word count must be between 1000 and 50000", + targetWordCountSpinner + ) + } + + val numberOfSections = numberOfSectionsSpinner.value as Int + if (numberOfSections < 3 || numberOfSections > 15) { + return com.intellij.openapi.ui.ValidationInfo( + "Number of sections must be between 3 and 15", + numberOfSectionsSpinner + ) + } + + val revisionPasses = revisionPassesSpinner.value as Int + if (revisionPasses < 0 || revisionPasses > 5) { + return com.intellij.openapi.ui.ValidationInfo( + "Revision passes must be between 0 and 5", + revisionPassesSpinner + ) + } + + return null + } + + fun getTaskConfig(): ResearchPaperGenerationTask.ResearchPaperGenerationTaskExecutionConfigData { + val inputFiles = inputFilesField.text.split(",").map { it.trim() }.filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + val researchFiles = researchFilesField.text.split(",").map { it.trim() }.filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + return ResearchPaperGenerationTask.ResearchPaperGenerationTaskExecutionConfigData( + research_topic = researchTopicArea.text, + paper_type = paperTypeCombo.selectedItem as String, + academic_level = academicLevelCombo.selectedItem as String, + target_word_count = targetWordCountSpinner.value as Int, + citation_style = citationStyleCombo.selectedItem as String, + include_literature_review = includeLiteratureReviewCheckbox.isSelected, + include_methodology = includeMethodologyCheckbox.isSelected, + include_statistical_analysis = includeStatisticalAnalysisCheckbox.isSelected, + include_peer_review = includePeerReviewCheckbox.isSelected, + number_of_sections = numberOfSectionsSpinner.value as Int, + revision_passes = revisionPassesSpinner.value as Int, + research_files = researchFiles, + input_files = inputFiles, + state = TaskState.Pending + ) + } + + fun getOrchestrationConfig(): OrchestrationConfig { + val selectedModel = modelCombo.selectedItem as? String + val model = selectedModel?.let { modelName -> + visibleModelsCache.find { it.modelName == modelName }?.toApiChatModel() + } + + return OrchestrationConfig( + defaultModel = model ?: AppSettingsState.instance.smartModel + ?: throw IllegalStateException("No model configured"), + parsingModel = AppSettingsState.instance.fastModel + ?: throw IllegalStateException("Fast model not configured"), + temperature = temperatureSlider.value / 100.0, + autoFix = autoFixCheckbox.isSelected, + workingDir = root.absolutePath, + shellCmd = listOf( + if (System.getProperty("os.name").lowercase().contains("win")) "powershell" else "bash" + ) + ) + } + + private fun getVisibleModels() = + ApplicationServices.fileApplicationServices().userSettingsManager.getUserSettings().apis.flatMap { apiData -> + apiData.provider?.getChatModels(apiData.key!!, apiData.baseUrl)?.filter { model -> + model.provider == apiData.provider && + model.modelName?.isNotBlank() == true && + PlanConfigDialog.isVisible(model) + } ?: listOf() + }.distinctBy { it.modelName }.sortedBy { "${it.provider?.name} - ${it.modelName}" } + } + +} \ No newline at end of file diff --git a/intellij/src/main/resources/META-INF/plugin.xml b/intellij/src/main/resources/META-INF/plugin.xml index 8287529ad..fc7ffe4c5 100644 --- a/intellij/src/main/resources/META-INF/plugin.xml +++ b/intellij/src/main/resources/META-INF/plugin.xml @@ -10,7 +10,7 @@ - + - - + + + + + + + + + + initSettings(session: Session): T = OrchestrationConfig() as T diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/apps/parse/DocumentParserApp.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/apps/parse/DocumentParserApp.kt index 8e3c1b1ee..350be8c6a 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/apps/parse/DocumentParserApp.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/apps/parse/DocumentParserApp.kt @@ -24,7 +24,7 @@ open class DocumentParserApp( applicationName: String = "Document Extractor", path: String = "/pdfExtractor", val parsingModel: ParsingModel, - val reader: (File) -> DocumentReader = { it.getReader() }, + val reader: (File) -> DocumentReader = { it.getDocumentReader() }, val fileInputs: List? = null, val fastMode: Boolean = true ) : ApplicationServer( diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/AbstractTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/AbstractTask.kt index 205e6d1d5..854a00604 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/AbstractTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/AbstractTask.kt @@ -1,13 +1,16 @@ package com.simiacryptus.cognotik.plan +import com.simiacryptus.cognotik.input.getDocumentReader +import com.simiacryptus.cognotik.input.isDocumentFile +import com.simiacryptus.cognotik.util.FileSelectionUtils import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.set import com.simiacryptus.cognotik.webui.session.SessionTask import com.simiacryptus.cognotik.webui.session.SocketManager import java.io.File -import java.io.FileOutputStream +import java.nio.file.FileSystems import java.nio.file.Path -import java.text.SimpleDateFormat +import kotlin.io.path.exists abstract class AbstractTask( val orchestrationConfig: OrchestrationConfig, @@ -60,6 +63,43 @@ abstract class AbstractTask( orchestrationConfig: OrchestrationConfig, ) + fun getInputFileContent( + files: List?, + root: Path, + treatDocumentsAsText: Boolean = true, + ): String = (files ?: listOf()) + .flatMap { pattern: String -> + if(root.resolve(pattern).exists()) { + return@flatMap listOf(root.resolve(pattern).toFile()) + } + val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") + (FileSelectionUtils.filteredWalk(root.toFile(), treatDocumentsAsText=treatDocumentsAsText) { + when { + FileSelectionUtils.isLLMIgnored(it.toPath()) -> false + it.isDirectory -> true + !matcher.matches(root.relativize(it.toPath())) -> false + else -> true + } + }) + }.filter { file -> + file.isFile && file.exists() + } + .distinct() + .sortedBy { it } + .joinToString("\n\n") { relativePath -> + val file = root.toFile().resolve(relativePath) + try { + if(treatDocumentsAsText && file.isDocumentFile()) { + file.getDocumentReader().getText() + } else { + "# $relativePath\n\n```\n${file.readText()}\n```" + } + } catch (e: Throwable) { + log.warn("Error reading file: $relativePath", e) + "" + } + } + companion object { val log = LoggerFactory.getLogger(AbstractTask::class.java) } diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/cognitive/AdaptivePlanningMode.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/cognitive/AdaptivePlanningMode.kt index ccd51b78f..1231d19f3 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/cognitive/AdaptivePlanningMode.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/cognitive/AdaptivePlanningMode.kt @@ -39,7 +39,7 @@ open class AdaptivePlanningMode( private val reasoningState = AtomicReference(null) private var isRunning = false private var transcriptStream: FileOutputStream? = null - private val expansionExpressionPattern = Regex("""\{([^|}{]+(?:\|[^|}{\n<>()\[\]]+)}""") + private val expansionExpressionPattern = Regex("""\{([^|}{]+(?:\|[^|}{\n<>()\[\]]+))}""") override fun initialize() { log.debug("Initializing AutoPlanMode") diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/file/AbstractFileTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/file/AbstractFileTask.kt index e1921ac97..1f2d9104a 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/file/AbstractFileTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/file/AbstractFileTask.kt @@ -2,7 +2,7 @@ package com.simiacryptus.cognotik.plan.tools.file import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.input.PaginatedDocumentReader -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.plan.OrchestrationConfig import com.simiacryptus.cognotik.plan.TaskExecutionConfig @@ -10,13 +10,9 @@ import com.simiacryptus.cognotik.plan.TaskTypeConfig import com.simiacryptus.cognotik.plan.tools.file.AbstractFileTask.FileTaskExecutionConfig import com.simiacryptus.cognotik.util.FileSelectionUtils import com.simiacryptus.cognotik.util.LoggerFactory -import com.simiacryptus.cognotik.webui.session.SessionTask import java.io.File -import java.io.FileOutputStream import java.nio.file.FileSystems import java.nio.file.Path -import java.text.SimpleDateFormat -import java.util.Date import kotlin.io.path.exists abstract class AbstractFileTask( @@ -53,9 +49,9 @@ abstract class AbstractFileTask( //path -> matcher.matches(root.relativize(path.toPath())) && !FileSelectionUtils.isLLMIgnored(path.toPath()) when { FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true it.isDirectory -> true - else -> false + !matcher.matches(root.relativize(it.toPath())) -> false + else -> true } }) } @@ -119,7 +115,7 @@ abstract class AbstractFileTask( fun extractDocumentContent(file: File): String { return try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) else -> reader.getText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/file/AnalysisTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/file/AnalysisTask.kt index ac9255be8..a79a69323 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/file/AnalysisTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/file/AnalysisTask.kt @@ -4,7 +4,7 @@ import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.chat.model.ChatInterface import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.input.PaginatedDocumentReader -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.models.ModelSchema import com.simiacryptus.cognotik.models.ModelSchema.Role import com.simiacryptus.cognotik.plan.* @@ -14,10 +14,8 @@ import com.simiacryptus.cognotik.util.* import com.simiacryptus.cognotik.webui.session.SessionTask import com.simiacryptus.cognotik.webui.session.getChildClient import java.io.File -import java.io.FileOutputStream import java.nio.file.FileSystems import java.nio.file.Path -import java.text.SimpleDateFormat import java.util.concurrent.Semaphore import java.util.concurrent.atomic.AtomicReference @@ -250,7 +248,7 @@ class AnalysisTask( } fun extractDocumentContent(file: File) = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) else -> reader.getText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/online/fetch/HttpClientFetch.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/online/fetch/HttpClientFetch.kt index 06532da7d..adfa232f0 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/online/fetch/HttpClientFetch.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/online/fetch/HttpClientFetch.kt @@ -1,6 +1,6 @@ package com.simiacryptus.cognotik.plan.tools.online.fetch -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.OrchestrationConfig import com.simiacryptus.cognotik.plan.tools.online.CrawlerAgentTask import com.simiacryptus.cognotik.util.HtmlSimplifier @@ -148,7 +148,7 @@ class HttpClientFetch : FetchMethodFactory { // Use DocumentReader to extract text val extractedText = try { - tempFile.getReader().use { reader -> + tempFile.getDocumentReader().use { reader -> reader.getText() } } catch (e: Exception) { diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AbductiveReasoningTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AbductiveReasoningTask.kt index 2d12829bb..659c5e7a1 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AbductiveReasoningTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AbductiveReasoningTask.kt @@ -5,7 +5,7 @@ import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.chat.model.ChatInterface import com.simiacryptus.cognotik.describe.Description -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.util.FileSelectionUtils import com.simiacryptus.cognotik.util.LoggerFactory @@ -627,7 +627,7 @@ AbductiveReasoning - Generate and evaluate explanatory hypotheses } private fun extractDocumentContent(file: File) = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> reader.getText() } } catch (e: Exception) { diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AbstractionLadderTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AbstractionLadderTask.kt index f9570b1f6..5f6077311 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AbstractionLadderTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AbstractionLadderTask.kt @@ -5,6 +5,7 @@ import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.chat.model.ChatInterface import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.plan.* +import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.MarkdownUtil import com.simiacryptus.cognotik.util.TabbedDisplay @@ -139,7 +140,7 @@ AbstractionLadder - Traverse abstraction levels to find patterns and design insi ) ) } - val inputFileContent = getInputFileContent() + val inputFileContent = super.getInputFileContent(executionConfig?.input_files, root, treatDocumentsAsText=true) val contextFiles = getContextFiles() @@ -458,36 +459,6 @@ AbstractionLadder - Traverse abstraction levels to find patterns and design insi } } - private fun getInputFileContent(): String { - val inputFiles = executionConfig?.input_files ?: emptyList() - if (inputFiles.isEmpty()) return "No input files provided." - return inputFiles.flatMap { pattern: String -> - val matcher = java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern") - (com.simiacryptus.cognotik.util.FileSelectionUtils.filteredWalk(root.toFile()) { - when { - com.simiacryptus.cognotik.util.FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }) - }.filter { file -> - file.isFile && file.exists() - } - .distinct() - .filterNotNull() - .sortedBy { it } - .joinToString("\n\n") { relativePath -> - val file = root.toFile().resolve(relativePath) - try { - val content = file.readText() - "# $relativePath\n\n```\n$content\n```" - } catch (e: Exception) { - log.warn("Error reading file: $relativePath", e) - "" - } - } - } private fun initializeDetailedOutput(task: SessionTask): FileOutputStream? { return try { diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AdversarialReasoningTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AdversarialReasoningTask.kt index e27860c51..479619593 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AdversarialReasoningTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AdversarialReasoningTask.kt @@ -3,7 +3,7 @@ package com.simiacryptus.cognotik.plan.tools.reasoning import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.describe.Description -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.TabbedDisplay @@ -714,7 +714,7 @@ AdversarialReasoning - Red team analysis to identify vulnerabilities and weaknes } private fun extractDocumentContent(file: java.io.File): String = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is com.simiacryptus.cognotik.input.PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) else -> reader.getText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AnalogicalReasoningTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AnalogicalReasoningTask.kt index c03f9d73a..61b6da472 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AnalogicalReasoningTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/AnalogicalReasoningTask.kt @@ -5,6 +5,7 @@ import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.plan.* +import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.TabbedDisplay import com.simiacryptus.cognotik.util.ValidatedObject @@ -207,7 +208,7 @@ AnalogicalReasoning - Solve problems by finding and applying analogies from diff log.debug("Gathering prior context and related files") val priorContext = getPriorCode(agent.executionState) val contextFiles = getContextFiles() - val inputFileContent = getInputFileContent() + val inputFileContent = super.getInputFileContent(executionConfig?.input_files, root, treatDocumentsAsText=true) transcriptStream?.let { stream -> writeToTranscript(stream, "## Input Files Context\n\n$inputFileContent\n\n") } @@ -727,44 +728,6 @@ Provide a brief validation assessment. } } - private fun getInputFileContent(): String { - val inputFiles = executionConfig?.input_files ?: return "" - if (inputFiles.isEmpty()) return "" - log.debug("Loading ${inputFiles.size} input files") - return buildString { - appendLine("## Input Files") - appendLine() - inputFiles.forEach { pattern: String -> - val matcher = java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern") - try { - val files = com.simiacryptus.cognotik.util.FileSelectionUtils.filteredWalk(root.toFile()) { - when { - com.simiacryptus.cognotik.util.FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }.filter { it.isFile && it.exists() }.distinct().filterNotNull().sortedBy { it } - files.forEach { file -> - try { - val relativePath = root.toFile().toPath().relativize(file.toPath()) - val content = file.readText().truncateForDisplay(500) - appendLine("### $relativePath") - appendLine("```") - appendLine(content) - appendLine("```") - appendLine() - log.debug("Successfully loaded input file: $relativePath") - } catch (e: Exception) { - log.warn("Error reading input file: ${file.name}", e) - } - } - } catch (e: Exception) { - log.warn("Error processing input file pattern: $pattern", e) - } - } - } - } private fun String.truncateForDisplay(maxLength: Int = 1000): String { return if (this.length > maxLength) this.substring(0, maxLength) + "\n...(truncated)" else this diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/BrainstormingTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/BrainstormingTask.kt index 988a59199..c996ae94d 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/BrainstormingTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/BrainstormingTask.kt @@ -4,7 +4,7 @@ import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.input.PaginatedDocumentReader -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.util.* import com.simiacryptus.cognotik.webui.session.SessionTask @@ -842,7 +842,7 @@ Provide a well-structured, actionable summary now. } private fun extractDocumentContent(file: File) = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) else -> reader.getText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/ConstraintSatisfactionTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/ConstraintSatisfactionTask.kt index bce4c9204..13b072b73 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/ConstraintSatisfactionTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/ConstraintSatisfactionTask.kt @@ -3,13 +3,13 @@ package com.simiacryptus.cognotik.plan.tools.reasoning import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.plan.* +import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.plan.transcript import com.simiacryptus.cognotik.util.* import com.simiacryptus.cognotik.webui.session.SessionTask import org.slf4j.Logger import java.io.FileOutputStream import java.nio.charset.StandardCharsets -import java.nio.file.FileSystems import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -203,7 +203,7 @@ class ConstraintSatisfactionTask( val priorCode = getPriorCode(agent.executionState) - val inputFileContent = getInputFileContent() + val inputFileContent = super.getInputFileContent(executionConfig?.input_files, root, treatDocumentsAsText=true) val prompt = buildPrompt( problemDescription, @@ -392,34 +392,8 @@ class ConstraintSatisfactionTask( } } - private fun getInputFileContent(): String = (executionConfig?.input_files ?: listOf()) - .flatMap { pattern: String -> - val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") - (FileSelectionUtils.filteredWalk(root.toFile()) { - when { - FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }) - }.filter { file -> - file.isFile && file.exists() - } - .distinct() - .sortedBy { it } - .joinToString("\n\n") { relativePath -> - val file = root.toFile().resolve(relativePath) - try { - val content = file.readText() - "# $relativePath\n\n```\n$content\n```" - } catch (e: Throwable) { - log.warn("Error reading file: $relativePath", e) - "" - } - } - private fun buildPrompt( + private fun buildPrompt( problemDescription: String, hardConstraints: List, softConstraints: Map, diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/EthicalReasoningTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/EthicalReasoningTask.kt index b91897bf2..947ed1dac 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/EthicalReasoningTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/EthicalReasoningTask.kt @@ -2,8 +2,9 @@ package com.simiacryptus.cognotik.plan.tools.reasoning import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.describe.Description -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* +import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.util.* import com.simiacryptus.cognotik.webui.session.SessionTask import org.slf4j.Logger @@ -102,7 +103,7 @@ class EthicalReasoningTask( orchestrationConfig: OrchestrationConfig ) { val startTime = System.currentTimeMillis() - messages + getInputFileContent() + messages + super.getInputFileContent(executionConfig?.input_files, root, treatDocumentsAsText=true) log.info("Starting EthicalReasoning task for dilemma: ${executionConfig?.ethical_dilemma?.truncateForDisplay(200)}") // Validate configuration first executionConfig?.validate()?.let { validationError -> @@ -414,40 +415,7 @@ Provide a detailed synthesis and a clear final recommendation. } } - private fun getInputFileContent(): List { - return (executionConfig?.input_files ?: listOf()) - .flatMap { pattern: String -> - val matcher = java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern") - (FileSelectionUtils.filteredWalk(root.toFile()) { - when { - FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }) - }.filter { file -> - file.isFile && file.exists() - } - .distinct() - .sortedBy { it } - .mapNotNull { relativePath -> - val file = root.toFile().resolve(relativePath) - try { - val content = if (!isTextFile(file)) { - extractDocumentContent(file) - } else { - file.readText() - } - "# ${relativePath}\n\n```\n$content\n```" - } catch (e: Throwable) { - log.warn("Error reading file: $relativePath", e) - null - } - } - } - - private fun isTextFile(file: java.io.File): Boolean { + private fun isTextFile(file: java.io.File): Boolean { val textExtensions = setOf( "txt", "md", @@ -477,7 +445,7 @@ Provide a detailed synthesis and a clear final recommendation. } private fun extractDocumentContent(file: java.io.File) = try { - file.getReader().use { it.getText() } + file.getDocumentReader().use { it.getText() } } catch (e: Exception) { log.warn("Failed to extract content from ${file.name}", e) file.readText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/FiniteStateMachineTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/FiniteStateMachineTask.kt index fa9fcb440..62bbda2c4 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/FiniteStateMachineTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/FiniteStateMachineTask.kt @@ -3,7 +3,7 @@ package com.simiacryptus.cognotik.plan.tools.reasoning import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.input.PaginatedDocumentReader -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.MarkdownUtil @@ -766,7 +766,7 @@ Format as a clear table or structured list. } private fun extractDocumentContent(file: File) = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) else -> reader.getText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GameEconomyTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GameEconomyTask.kt index 57497f7b4..00c950621 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GameEconomyTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GameEconomyTask.kt @@ -3,7 +3,6 @@ package com.simiacryptus.cognotik.plan.tools.reasoning import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.describe.Description -import com.simiacryptus.cognotik.input.getReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.util.* import com.simiacryptus.cognotik.webui.session.SessionTask diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GameTheoryTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GameTheoryTask.kt index 4a5bbfb95..53ac13335 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GameTheoryTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GameTheoryTask.kt @@ -3,7 +3,7 @@ package com.simiacryptus.cognotik.plan.tools.reasoning import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.describe.Description -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.util.* import com.simiacryptus.cognotik.webui.session.SessionTask @@ -56,7 +56,7 @@ class GameTheoryTask( } fun extractDocumentContent(file: java.io.File) = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is com.simiacryptus.cognotik.input.PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GeneticOptimizationTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GeneticOptimizationTask.kt index 0f2d40e74..2b7a63524 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GeneticOptimizationTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/GeneticOptimizationTask.kt @@ -6,6 +6,7 @@ import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.chat.model.ChatInterface import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.plan.* +import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.TabbedDisplay import com.simiacryptus.cognotik.util.ValidatedObject @@ -206,7 +207,7 @@ GeneticOptimization - Iteratively evolve and perfect text through genetic algori "goal_alignment" to 0.15 ) val constraints = executionConfig?.constraints ?: emptyList() - val inputFileContent = getInputFileContent() + val inputFileContent = super.getInputFileContent(executionConfig?.input_files, root, treatDocumentsAsText=true) if (initialText.isNullOrBlank() || optimizationGoal.isNullOrBlank()) { log.error("Configuration error: initial_text or optimization_goal is blank") @@ -743,29 +744,6 @@ GeneticOptimization - Iteratively evolve and perfect text through genetic algori return markdownTranscript } - private fun getInputFileContent(): String { - return (executionConfig?.input_files ?: listOf()) - .flatMap { pattern: String -> - val matcher = java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern") - (com.simiacryptus.cognotik.util.FileSelectionUtils.filteredWalk(root.toFile()) { - when { - com.simiacryptus.cognotik.util.FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }) - }.filter { file -> - file.isFile && file.exists() - } - .distinct() - .sortedBy { it } - .joinToString("\n\n") { relativePath -> - val file = root.toFile().resolve(relativePath) - "# $relativePath\n\n${file.readText()}" - } - } - private fun generateMutation( text: String, diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/LateralThinkingTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/LateralThinkingTask.kt index 815d89ddb..688513dd4 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/LateralThinkingTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/reasoning/LateralThinkingTask.kt @@ -5,7 +5,7 @@ import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.input.PaginatedDocumentReader -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.platform.model.ApiChatModel import com.simiacryptus.cognotik.util.FileSelectionUtils @@ -16,7 +16,6 @@ import com.simiacryptus.cognotik.webui.session.SessionTask import com.simiacryptus.cognotik.webui.session.getChildClient import org.slf4j.Logger import java.io.File -import java.io.FileOutputStream import java.nio.file.FileSystems import java.nio.file.Path import java.time.LocalDateTime @@ -1280,7 +1279,7 @@ Generate $numAlternatives ideas using $technique. } private fun extractDocumentContent(file: File) = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) else -> reader.getText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/BusinessProposalTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/BusinessProposalTask.kt index f8bd379d2..e1ce2ccf2 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/BusinessProposalTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/BusinessProposalTask.kt @@ -5,17 +5,15 @@ import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.plan.* +import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.plan.tools.reasoning.safeComplete import com.simiacryptus.cognotik.plan.tools.reasoning.truncateForDisplay import com.simiacryptus.cognotik.plan.tools.reasoning.validateAndGetApi -import com.simiacryptus.cognotik.util.FileSelectionUtils import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.TabbedDisplay import com.simiacryptus.cognotik.util.ValidatedObject import com.simiacryptus.cognotik.webui.session.SessionTask import org.slf4j.Logger -import java.io.FileOutputStream -import java.nio.file.FileSystems import java.text.SimpleDateFormat import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -86,10 +84,10 @@ class BusinessProposalTask( @Description("Related files or research to incorporate") val related_files: List? = null, + @Description("The specific files (or file patterns, e.g. **/*.kt) to be used as input for the task") val input_files: List? = null, - task_description: String? = null, task_dependencies: List? = null, state: TaskState? = TaskState.Pending, @@ -417,7 +415,7 @@ BusinessProposal - Generate comprehensive business proposals with ROI analysis a val resultBuilder = StringBuilder() resultBuilder.append("# Business Proposal: $proposalTitle\n\n") // Load input files if specified - val inputFileContent = getInputFileContent() + val inputFileContent = super.getInputFileContent(executionConfig?.input_files, root, treatDocumentsAsText=true) val messagesWithContext = if (inputFileContent.isNotBlank()) { messages + listOf( "## Input Files Context\n\n$inputFileContent" @@ -1631,41 +1629,6 @@ Provide the complete revised proposal. } } - private fun getInputFileContent(): String { - val inputFiles = executionConfig?.input_files ?: return "" - if (inputFiles.isEmpty()) return "" - log.debug("Loading ${inputFiles.size} input files") - return inputFiles - .flatMap { pattern: String -> - val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") - (FileSelectionUtils.filteredWalk(root.toFile()) { - when { - FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }) - }.filter { file -> - file.isFile && file.exists() - } - .distinct() - .filterNotNull() - .sortedBy { it } - .joinToString("\n\n") { relativePath -> - val file = root.toFile().resolve(relativePath) - try { - val content = file.readText() - "# $relativePath\n\n```\n$content\n```" - } catch (e: Throwable) { - log.warn("Error reading file: $relativePath", e) - "" - } - } - } - - - companion object { private val log: Logger = LoggerFactory.getLogger(BusinessProposalTask::class.java) diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/JournalismReasoningTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/JournalismReasoningTask.kt index 54b5484cc..04fb4ce3b 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/JournalismReasoningTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/JournalismReasoningTask.kt @@ -5,10 +5,10 @@ import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.plan.* +import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.plan.tools.reasoning.safeComplete import com.simiacryptus.cognotik.plan.tools.reasoning.truncateForDisplay import com.simiacryptus.cognotik.plan.tools.reasoning.validateAndGetApi -import com.simiacryptus.cognotik.util.FileSelectionUtils import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.TabbedDisplay import com.simiacryptus.cognotik.util.ValidatedObject @@ -16,7 +16,6 @@ import com.simiacryptus.cognotik.webui.session.SessionTask import org.slf4j.Logger import java.io.FileOutputStream import java.nio.charset.StandardCharsets -import java.nio.file.FileSystems import java.text.SimpleDateFormat import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -257,32 +256,6 @@ JournalismReasoning - Investigate stories through journalistic principles and me return markdownTranscript } - private fun getInputFileContent(): String { - val inputFiles = executionConfig?.input_files ?: return "" - if (inputFiles.isEmpty() || !executionConfig?.include_file_content!!) { - return "" - } - return inputFiles - .flatMap { pattern: String -> - val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") - (FileSelectionUtils.filteredWalk(root.toFile()) { - when { - FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }) - }.filter { file -> - file.isFile && file.exists() - } - .distinct() - .sortedBy { it } - .joinToString("\n\n") { file -> - "# ${root.relativize(file.toPath())}\n\n```\n${file.readText()}\n```" - } - } - override fun run( agent: TaskOrchestrator, @@ -364,7 +337,7 @@ JournalismReasoning - Investigate stories through journalistic principles and me writer.appendLine() writer.flush() // Include file content if requested - val fileContent = getInputFileContent() + val fileContent = super.getInputFileContent(executionConfig.input_files, root, treatDocumentsAsText=true) if (fileContent.isNotBlank()) { writer.appendLine("## Input Files") writer.appendLine() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/NarrativeGenerationTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/NarrativeGenerationTask.kt index d99a50b5a..1f41c7e1e 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/NarrativeGenerationTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/NarrativeGenerationTask.kt @@ -11,7 +11,6 @@ import com.simiacryptus.cognotik.plan.OrchestrationConfig import com.simiacryptus.cognotik.plan.TaskOrchestrator import com.simiacryptus.cognotik.plan.TaskType import com.simiacryptus.cognotik.plan.TaskTypeConfig -import com.simiacryptus.cognotik.plan.tools.file.AnalysisTask import com.simiacryptus.cognotik.plan.tools.reasoning.safeComplete import com.simiacryptus.cognotik.plan.tools.reasoning.truncateForDisplay import com.simiacryptus.cognotik.plan.tools.reasoning.validateAndGetApi @@ -21,7 +20,6 @@ import com.simiacryptus.cognotik.webui.chat.transcriptFilter import com.simiacryptus.cognotik.webui.session.SessionTask import org.slf4j.Logger import java.io.BufferedWriter -import java.io.File import java.text.SimpleDateFormat import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -212,7 +210,8 @@ NarrativeGeneration - Generate complete narratives from analysis and outlines val tabs = TabbedDisplay(task) // Get input file context - val inputFileContext = getInputFileCode(agent.root.toFile()) + agent.root.toFile() + val inputFileContext = super.getInputFileContent(executionConfig.input_files, root, treatDocumentsAsText=true) if (inputFileContext.isNotBlank()) { transcript?.write("## Input Files Context\n\n$inputFileContext\n\n") transcript?.flush() @@ -735,40 +734,6 @@ Provide the revised scene content only. return java.io.BufferedWriter(markdownTranscript?.let { java.io.OutputStreamWriter(it) }) } - private fun getInputFileCode(rootFile: File): String { - val executionConfig = executionConfig as? NarrativeGenerationTaskExecutionConfigData ?: return "" - return (executionConfig.input_files ?: listOf()) - .flatMap { pattern: String -> - val matcher = java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern") - (com.simiacryptus.cognotik.util.FileSelectionUtils.filteredWalk(rootFile) { - when { - com.simiacryptus.cognotik.util.FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(rootFile.toPath().relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }) - }.filter { file -> - file.isFile && file.exists() - } - .distinct() - .sortedBy { it } - .joinToString("\n\n") { relativePath -> - val file = rootFile.resolve(relativePath.name) - try { - val content = if (!AnalysisTask.isTextFile(file)) { - AnalysisTask.extractDocumentContent(file) - } else { - file.readText() - } - "# ${relativePath.name}\n\n```\n$content\n```" - } catch (e: Throwable) { - log.warn("Error reading file: ${relativePath.name}", e) - "" - } - } - } - private fun generateCoverImage( task: SessionTask, tabs: TabbedDisplay, diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/NarrativeReasoningTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/NarrativeReasoningTask.kt index 6f0a5a0fd..1d4177608 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/NarrativeReasoningTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/NarrativeReasoningTask.kt @@ -23,6 +23,7 @@ import org.slf4j.Logger import java.io.File import java.nio.charset.StandardCharsets import java.nio.file.FileSystems +import java.nio.file.Path import java.time.LocalDateTime import java.time.format.DateTimeFormatter import javax.imageio.ImageIO @@ -284,7 +285,7 @@ NarrativeReasoning - Understand scenarios through storytelling and narrative str return } // Read input files if specified - val inputFileContent = getInputFileContent() + val inputFileContent = getInputFileContent(executionConfig?.input_files, agent.root) val messageContent = messages.joinToString("\n\n") val additionalContext = buildString { if (messageContent.isNotBlank()) { @@ -394,7 +395,6 @@ NarrativeReasoning - Understand scenarios through storytelling and narrative str transcriptWriter?.appendLine("- Find Inconsistencies: $findInconsistencies") transcriptWriter?.appendLine() - try { // Step 1: Construct the main narrative if (constructNarrative) { @@ -407,7 +407,6 @@ NarrativeReasoning - Understand scenarios through storytelling and narrative str transcriptWriter?.appendLine("## Step 1: Main Narrative Construction") transcriptWriter?.appendLine() - narrativeTask.add( buildString { appendLine("# Main Narrative Construction") @@ -427,9 +426,6 @@ NarrativeReasoning - Understand scenarios through storytelling and narrative str Narrative Elements: ${narrativeElements.entries.joinToString("\n") { (key, value) -> "- $key: $value" }} -${if (additionalContext.isNotBlank()) "Additional Context:\n$additionalContext\n" else ""} - - ${if (priorContext.isNotBlank()) "Additional Context:\n$priorContext\n" else ""} Create a structured narrative with: @@ -1158,33 +1154,6 @@ Be concise but insightful. Focus on actionable insights. } } - private fun getInputFileContent(): String = (executionConfig?.input_files ?: listOf()) - .flatMap { pattern: String -> - val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") - (FileSelectionUtils.filteredWalk(root.toFile()) { - when { - FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }) - }.filter { file -> - file.isFile && file.exists() - } - .distinct() - .sortedBy { it } - .joinToString("\n\n") { relativePath -> - val file = root.toFile().resolve(relativePath) - try { - val content = file.readText() - "# $relativePath\n\n```\n$content\n```" - } catch (e: Throwable) { - log.warn("Error reading file: $relativePath", e) - "" - } - } - private fun saveAnalysisToFile( outputDir: File, filename: String, @@ -1317,4 +1286,5 @@ ${description.indent(" ")} """ ) } -} \ No newline at end of file +} + diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/PersuasiveEssayTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/PersuasiveEssayTask.kt index 50ce15b27..9dc252622 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/PersuasiveEssayTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/PersuasiveEssayTask.kt @@ -5,18 +5,17 @@ import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.describe.Description import com.simiacryptus.cognotik.plan.* +import com.simiacryptus.cognotik.plan.AbstractTask import com.simiacryptus.cognotik.plan.tools.reasoning.safeComplete import com.simiacryptus.cognotik.plan.tools.reasoning.truncateForDisplay import com.simiacryptus.cognotik.plan.tools.reasoning.validateAndGetApi import com.simiacryptus.cognotik.plan.transcript -import com.simiacryptus.cognotik.util.FileSelectionUtils import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.TabbedDisplay import com.simiacryptus.cognotik.util.ValidatedObject import com.simiacryptus.cognotik.webui.session.SessionTask import org.slf4j.Logger import java.nio.charset.StandardCharsets -import java.nio.file.FileSystems import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -268,7 +267,7 @@ class PersuasiveEssayTask( try { // Gather context val priorContext = getPriorCode(agent.executionState) - val inputFileContent = getInputFileContent() + val inputFileContent = super.getInputFileContent(executionConfig.input_files, root, treatDocumentsAsText=true) val contextFiles = getContextFiles() if (priorContext.isNotBlank() || inputFileContent.isNotBlank() || contextFiles.isNotBlank()) { @@ -1027,39 +1026,6 @@ Provide the complete revised essay. } } - private fun getInputFileContent(): String { - val inputFiles = executionConfig?.input_files ?: return "" - if (inputFiles.isEmpty()) return "" - log.debug("Loading ${inputFiles.size} input files") - return buildString { - appendLine("## Input Files Content") - appendLine() - inputFiles.forEach { pattern: String -> - try { - val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") - val matchedFiles = FileSelectionUtils.filteredWalk(root.toFile()) { - when { - FileSelectionUtils.isLLMIgnored(it.toPath()) -> false - matcher.matches(root.relativize(it.toPath())) -> true - it.isDirectory -> true - else -> false - } - }.filter { it.isFile && it.exists() }.distinct().sortedBy { it } - matchedFiles.forEach { file -> - log.debug("Loading input file: ${file.path}") - appendLine("### ${root.relativize(file.toPath())}") - appendLine("```") - appendLine(file.readText().truncateForDisplay(1000)) - appendLine("```") - appendLine() - } - } catch (e: Exception) { - log.warn("Error reading input files matching pattern: $pattern", e) - } - } - } - } - private fun getContextFiles(): String { val relatedFiles = executionConfig?.related_files ?: return "" diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/ReportGenerationTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/ReportGenerationTask.kt index 9d73db749..84a04c319 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/ReportGenerationTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/ReportGenerationTask.kt @@ -4,7 +4,7 @@ import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.describe.Description -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.plan.tools.reasoning.safeComplete import com.simiacryptus.cognotik.plan.tools.reasoning.truncateForDisplay @@ -1229,7 +1229,7 @@ Provide the complete revised report. } private fun extractDocumentContent(file: java.io.File) = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is com.simiacryptus.cognotik.input.PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) else -> reader.getText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/ResearchPaperGenerationTask.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/ResearchPaperGenerationTask.kt index b14155103..6f9bd4db1 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/ResearchPaperGenerationTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/plan/tools/writing/ResearchPaperGenerationTask.kt @@ -4,7 +4,7 @@ import com.simiacryptus.cognotik.agents.ChatAgent import com.simiacryptus.cognotik.agents.ParsedAgent import com.simiacryptus.cognotik.apps.general.renderMarkdown import com.simiacryptus.cognotik.describe.Description -import com.simiacryptus.cognotik.input.getReader +import com.simiacryptus.cognotik.input.getDocumentReader import com.simiacryptus.cognotik.plan.* import com.simiacryptus.cognotik.plan.tools.reasoning.safeComplete import com.simiacryptus.cognotik.plan.tools.reasoning.truncateForDisplay @@ -1063,7 +1063,7 @@ Provide the complete revised paper. } private fun extractDocumentContent(file: java.io.File) = try { - file.getReader().use { reader -> + file.getDocumentReader().use { reader -> when (reader) { is com.simiacryptus.cognotik.input.PaginatedDocumentReader -> reader.getText(0, reader.getPageCount()) else -> reader.getText() diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/application/ApplicationServer.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/application/ApplicationServer.kt index 0336f5a09..3722bd26a 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/application/ApplicationServer.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/application/ApplicationServer.kt @@ -21,10 +21,11 @@ import com.simiacryptus.cognotik.webui.session.SocketManager import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.eclipse.jetty.servlet.FilterHolder -import org.eclipse.jetty.servlet.ServletHolder -import org.eclipse.jetty.webapp.WebAppContext -import org.slf4j.Logger -import java.io.File + import org.eclipse.jetty.servlet.ServletHolder + import org.eclipse.jetty.webapp.WebAppContext +import jakarta.servlet.MultipartConfigElement + import org.slf4j.Logger + import java.io.File abstract class ApplicationServer( final override val applicationName: String, @@ -56,7 +57,16 @@ abstract class ApplicationServer( protected open val userInfo by lazy { ServletHolder("userInfo", UserInfoServlet()) } protected open val usageServlet by lazy { ServletHolder("usage", UsageServlet()) } protected open val fileZip by lazy { ServletHolder("fileZip", ZipServlet(dataStorage)) } - protected open val fileIndex by lazy { ServletHolder("fileIndex", SessionFileServlet(dataStorage)) } + protected open val fileIndex by lazy { + ServletHolder("fileIndex", SessionFileServlet(dataStorage)).apply { + registration.setMultipartConfig(MultipartConfigElement( + System.getProperty("java.io.tmpdir"), + 1024L * 1024L * 50L, // maxFileSize: 50MB + 1024L * 1024L * 100L, // maxRequestSize: 100MB + 1024 * 1024 * 2 // fileSizeThreshold: 2MB + )) + } + } protected open val sessionSettingsServlet by lazy { ServletHolder("settings", SessionSettingsServlet(this)) } protected open val sessionShareServlet by lazy { ServletHolder("share", SessionShareServlet(this)) } protected open val sessionThreadsServlet by lazy { ServletHolder("threads", SessionThreadsServlet()) } diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/servlet/FileServlet.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/servlet/FileServlet.kt index 0bfb239ee..a4b92107c 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/servlet/FileServlet.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/servlet/FileServlet.kt @@ -10,216 +10,305 @@ import com.vladsch.flexmark.html.HtmlRenderer import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.util.data.MutableDataSet import jakarta.servlet.WriteListener +import jakarta.servlet.annotation.MultipartConfig import jakarta.servlet.http.HttpServlet import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import jakarta.servlet.http.Part import org.eclipse.jetty.http.MimeTypes import java.io.ByteArrayOutputStream import java.io.File import java.nio.ByteBuffer import java.nio.MappedByteBuffer import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption +@MultipartConfig( + fileSizeThreshold = 1024 * 1024 * 2, // 2MB + maxFileSize = 1024 * 1024 * 50, // 50MB + maxRequestSize = 1024 * 1024 * 100 // 100MB +) abstract class FileServlet : HttpServlet() { - abstract fun getDir( - req: HttpServletRequest, - ): File? + abstract fun getDir( + req: HttpServletRequest, + ): File? - override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { - log.info("Received GET request for path: ${req.pathInfo ?: req.servletPath}") - val pathSegments = parsePath(req.pathInfo ?: req.servletPath ?: "/") - val dir = getDir(req) - val file = dir?.let { getFile(it, pathSegments, req) } + override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { + log.info("Received GET request for path: ${req.pathInfo ?: req.servletPath}") + val pathSegments = parsePath(req.pathInfo ?: req.servletPath ?: "/") + val dir = getDir(req) + val file = dir?.let { getFile(it, pathSegments, req) } - when { - false == file?.exists() -> { - // Check if this is a request for HTML or PDF with an equivalent .md file - val fileName = file.name when { - (fileName.endsWith(".html") || fileName.endsWith(".pdf")) -> { - val mdFile = File(file.parentFile, fileName.substringBeforeLast(".") + ".md") - if (mdFile.exists() && mdFile.isFile) { - log.info("Found markdown file, rendering: ${mdFile.absolutePath}") - renderMarkdown(mdFile, resp, fileName.endsWith(".pdf")) - } else { - log.warn("File not found: ${file.absolutePath}") - resp.status = HttpServletResponse.SC_NOT_FOUND - resp.writer.write("File not found") + false == file?.exists() -> { + // Check if this is a request for HTML or PDF with an equivalent .md file + val fileName = file.name + when { + (fileName.endsWith(".html") || fileName.endsWith(".pdf")) -> { + val mdFile = File(file.parentFile, fileName.substringBeforeLast(".") + ".md") + if (mdFile.exists() && mdFile.isFile) { + log.info("Found markdown file, rendering: ${mdFile.absolutePath}") + renderMarkdown(mdFile, resp, fileName.endsWith(".pdf")) + } else { + log.warn("File not found: ${file.absolutePath}") + resp.status = HttpServletResponse.SC_NOT_FOUND + resp.writer.write("File not found") + } + } + + else -> { + log.warn("File not found: ${file.absolutePath}") + resp.status = HttpServletResponse.SC_NOT_FOUND + resp.writer.write("File not found") + } + } } - } - else -> { - log.warn("File not found: ${file.absolutePath}") - resp.status = HttpServletResponse.SC_NOT_FOUND - resp.writer.write("File not found") - } - } - } - - true == file?.isFile -> { - log.info("File found: ${file.absolutePath}") - var channel = channelCache.get(file) - while (!channel.isOpen) { - log.warn("FileChannel is not open, refreshing cache for file: ${file.absolutePath}") - channelCache.refresh(file) - channel = channelCache.get(file) + true == file?.isFile -> { + log.info("File found: ${file.absolutePath}") + var channel = channelCache.get(file) + while (!channel.isOpen) { + log.warn("FileChannel is not open, refreshing cache for file: ${file.absolutePath}") + channelCache.refresh(file) + channel = channelCache.get(file) + } + try { + if (channel.size() > 1024 * 1024 * 1) { + log.info("File is large, using writeLarge method for file: ${file.absolutePath}") + writeLarge(channel, resp, file, req) + } else { + log.info("File is small, using writeSmall method for file: ${file.absolutePath}") + writeSmall(channel, resp, file, req) + } + } finally { + + } + } + + req.pathInfo?.endsWith("/") == false -> { + log.info("Redirecting to directory path: ${req.requestURI + "/"}") + resp.sendRedirect(req.requestURI + "/") + } + + else -> { + resp.contentType = "text/html" + resp.characterEncoding = "UTF-8" + resp.status = HttpServletResponse.SC_OK + val currentPathString = pathSegments.drop(1).joinToString("/") + val servletPathBase = + req.contextPath + req.servletPath.removeSuffix("/*") + .removeSuffix("/") + "/" + req.pathInfo.split("/").firstOrNull { it.isNotBlank() } + + val (files, folders) = listContents(file, req) + resp.writer.write( + directoryHTML( + currentPathString, + servletPathBase, + getZipLink(req, currentPathString), + folders, + files + ) + ) + } } + } + + override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { + log.info("Received POST request for file upload at path: ${req.pathInfo ?: req.servletPath}") try { - if (channel.size() > 1024 * 1024 * 1) { - log.info("File is large, using writeLarge method for file: ${file.absolutePath}") - writeLarge(channel, resp, file, req) - } else { - log.info("File is small, using writeSmall method for file: ${file.absolutePath}") - writeSmall(channel, resp, file, req) - } - } finally { + val pathSegments = parsePath(req.pathInfo ?: req.servletPath ?: "/") + val dir = getDir(req) + val targetDir = dir?.let { getFile(it, pathSegments, req) } + if (targetDir == null || !targetDir.exists() || !targetDir.isDirectory) { + log.warn("Target directory does not exist or is not a directory: ${targetDir?.absolutePath}") + resp.status = HttpServletResponse.SC_BAD_REQUEST + resp.writer.write("Invalid target directory") + return + } + val filePart: Part? = req.getPart("file") + if (filePart == null) { + log.warn("No file part found in upload request") + resp.status = HttpServletResponse.SC_BAD_REQUEST + resp.writer.write("No file uploaded") + return + } + val fileName = getSubmittedFileName(filePart) + if (fileName.isNullOrBlank()) { + log.warn("No filename provided in upload request") + resp.status = HttpServletResponse.SC_BAD_REQUEST + resp.writer.write("No filename provided") + return + } + // Validate filename for security + if (!isValidFileName(fileName)) { + log.warn("Invalid filename attempted: $fileName") + resp.status = HttpServletResponse.SC_BAD_REQUEST + resp.writer.write("Invalid filename") + return + } + val targetFile = File(targetDir, fileName) + // Check if file already exists - no overwriting allowed + if (targetFile.exists()) { + log.warn("File already exists, overwriting not allowed: ${targetFile.absolutePath}") + resp.status = HttpServletResponse.SC_CONFLICT + resp.writer.write("File already exists. Overwriting is not allowed.") + return + } + // Save the uploaded file + filePart.inputStream.use { input -> + Files.copy(input, targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + log.info("File uploaded successfully: ${targetFile.absolutePath}") + resp.status = HttpServletResponse.SC_OK + resp.contentType = "application/json" + resp.writer.write("""{"success": true, "message": "File uploaded successfully", "filename": "$fileName"}""") + } catch (e: Exception) { + log.error("Error during file upload", e) + resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR + resp.writer.write("Error uploading file: ${e.message}") + } + } + private fun getSubmittedFileName(part: Part): String? { + val contentDisposition = part.getHeader("content-disposition") + if (contentDisposition != null) { + for (token in contentDisposition.split(";")) { + if (token.trim().startsWith("filename")) { + return token.substring(token.indexOf('=') + 1).trim().trim('"') + } + } } - } + return null + } - req.pathInfo?.endsWith("/") == false -> { - log.info("Redirecting to directory path: ${req.requestURI + "/"}") - resp.sendRedirect(req.requestURI + "/") - } + private fun isValidFileName(fileName: String): Boolean { + // Reject path traversal attempts and invalid characters + return !fileName.contains("..") && + !fileName.contains("/") && + !fileName.contains("\\") && + !fileName.contains(":") && + !fileName.contains("~") && + fileName.isNotBlank() && + fileName.all { it.code >= 32 && it.code <= 126 } + } - else -> { - resp.contentType = "text/html" - resp.characterEncoding = "UTF-8" - resp.status = HttpServletResponse.SC_OK - val currentPathString = pathSegments.drop(1).joinToString("/") - val servletPathBase = - req.contextPath + req.servletPath.removeSuffix("/*") - .removeSuffix("/") + "/" + req.pathInfo.split("/").firstOrNull { it.isNotBlank() } - - val (files, folders) = listContents(file, req) - resp.writer.write( - directoryHTML( - currentPathString, - servletPathBase, - getZipLink(req, currentPathString), - folders, - files - ) - ) - } + + open fun listContents(file: File?, req: HttpServletRequest): Pair { + val files = file?.listFiles() + ?.filter { it.isFile } + ?.sortedBy { it.name } + ?.joinToString("") { + val fileName = it.name + val baseLink = """📄${fileName}""" + val htmlLink = if (fileName.endsWith(".md")) { + val htmlFileName = fileName.substringBeforeLast(".") + ".html" + """ 🌐View as HTML""" + } else { + "" + } + """
  • $baseLink$htmlLink
  • """ + } ?: "" + val folders = file?.listFiles() + ?.filter { !it.isFile } + ?.sortedBy { it.name } + ?.joinToString("") { + """
  • 📁${it.name}
  • """ + } ?: "" + return Pair(files, folders) } - } - - open fun listContents(file: File?, req: HttpServletRequest): Pair { - val files = file?.listFiles() - ?.filter { it.isFile } - ?.sortedBy { it.name } - ?.joinToString("") { - val fileName = it.name - val baseLink = """📄${fileName}""" - val htmlLink = if (fileName.endsWith(".md")) { - val htmlFileName = fileName.substringBeforeLast(".") + ".html" - """ 🌐View as HTML""" - } else { - "" - } - """
  • $baseLink$htmlLink
  • """ - } ?: "" - val folders = file?.listFiles() - ?.filter { !it.isFile } - ?.sortedBy { it.name } - ?.joinToString("") { - """
  • 📁${it.name}
  • """ - } ?: "" - return Pair(files, folders) - } - // getFile should construct the file path using all pathSegments relative to the base dir - - open fun getFile(dir: File, pathSegments: List, req: HttpServletRequest) = - File(dir, pathSegments.drop(1).joinToString("/")) - - private fun writeSmall(channel: FileChannel, resp: HttpServletResponse, file: File, req: HttpServletRequest) { - log.info("Writing small file: ${file.absolutePath}") - resp.contentType = getMimeType(file.name) - resp.status = HttpServletResponse.SC_OK - val async = req.startAsync() - resp.outputStream.apply { - setWriteListener(object : WriteListener { - val buffer = ByteArray(16 * 1024) - val byteBuffer = ByteBuffer.wrap(buffer) - override fun onWritePossible() { - while (isReady) { - byteBuffer.clear() - val readBytes = channel.read(byteBuffer) - if (readBytes == -1) { - log.info("Completed writing small file: ${file.absolutePath}") - async.complete() - channelCache.put(file, channel) - return - } - write(buffer, 0, readBytes) - } - } + // getFile should construct the file path using all pathSegments relative to the base dir - override fun onError(throwable: Throwable) { - log.error("Error writing small file: ${file.absolutePath}", throwable) - channelCache.put(file, channel) + open fun getFile(dir: File, pathSegments: List, req: HttpServletRequest) = + File(dir, pathSegments.drop(1).joinToString("/")) + + private fun writeSmall(channel: FileChannel, resp: HttpServletResponse, file: File, req: HttpServletRequest) { + log.info("Writing small file: ${file.absolutePath}") + resp.contentType = getMimeType(file.name) + resp.status = HttpServletResponse.SC_OK + val async = req.startAsync() + resp.outputStream.apply { + setWriteListener(object : WriteListener { + val buffer = ByteArray(16 * 1024) + val byteBuffer = ByteBuffer.wrap(buffer) + override fun onWritePossible() { + while (isReady) { + byteBuffer.clear() + val readBytes = channel.read(byteBuffer) + if (readBytes == -1) { + log.info("Completed writing small file: ${file.absolutePath}") + async.complete() + channelCache.put(file, channel) + return + } + write(buffer, 0, readBytes) + } + } + + override fun onError(throwable: Throwable) { + log.error("Error writing small file: ${file.absolutePath}", throwable) + channelCache.put(file, channel) + } + }) } - }) } - } - - private fun writeLarge( - channel: FileChannel, - resp: HttpServletResponse, - file: File, - req: HttpServletRequest - ) { - log.info("Writing large file: ${file.absolutePath}") - val mappedByteBuffer: MappedByteBuffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()) - resp.contentType = getMimeType(file.name) - resp.status = HttpServletResponse.SC_OK - val async = req.startAsync() - resp.outputStream.apply { - setWriteListener(object : WriteListener { - val buffer = ByteArray(256 * 1024) - override fun onWritePossible() { - while (isReady) { - val start = mappedByteBuffer.position() - val attemptedReadSize = buffer.size.coerceAtMost(mappedByteBuffer.remaining()) - mappedByteBuffer.get(buffer, 0, attemptedReadSize) - val end = mappedByteBuffer.position() - val readBytes = end - start - if (readBytes == 0) { - log.info("Completed writing large file: ${file.absolutePath}") - async.complete() - channelCache.put(file, channel) - return - } - write(buffer, 0, readBytes) - } - } - override fun onError(throwable: Throwable) { - log.error("Error writing large file: ${file.absolutePath}", throwable) - channelCache.put(file, channel) + private fun writeLarge( + channel: FileChannel, + resp: HttpServletResponse, + file: File, + req: HttpServletRequest + ) { + log.info("Writing large file: ${file.absolutePath}") + val mappedByteBuffer: MappedByteBuffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()) + resp.contentType = getMimeType(file.name) + resp.status = HttpServletResponse.SC_OK + val async = req.startAsync() + resp.outputStream.apply { + setWriteListener(object : WriteListener { + val buffer = ByteArray(256 * 1024) + override fun onWritePossible() { + while (isReady) { + val start = mappedByteBuffer.position() + val attemptedReadSize = buffer.size.coerceAtMost(mappedByteBuffer.remaining()) + mappedByteBuffer.get(buffer, 0, attemptedReadSize) + val end = mappedByteBuffer.position() + val readBytes = end - start + if (readBytes == 0) { + log.info("Completed writing large file: ${file.absolutePath}") + async.complete() + channelCache.put(file, channel) + return + } + write(buffer, 0, readBytes) + } + } + + override fun onError(throwable: Throwable) { + log.error("Error writing large file: ${file.absolutePath}", throwable) + channelCache.put(file, channel) + } + }) } - }) } - } - - private fun renderMarkdown(mdFile: File, resp: HttpServletResponse, asPdf: Boolean) { - try { - val markdownContent = mdFile.readText() - val options = MutableDataSet() - val parser = Parser.builder(options).build() - val document = parser.parse(markdownContent) - val renderer = HtmlRenderer.builder(options).build() - val html = renderer.render(document) - - if (asPdf) { - val outputStream = ByteArrayOutputStream() - val baseUri = mdFile.parentFile.toURI().toString() - - // Wrap HTML with proper structure for PDF conversion - val fullHtml = """ + + private fun renderMarkdown(mdFile: File, resp: HttpServletResponse, asPdf: Boolean) { + try { + val markdownContent = mdFile.readText() + val options = MutableDataSet() + val parser = Parser.builder(options).build() + val document = parser.parse(markdownContent) + val renderer = HtmlRenderer.builder(options).build() + val html = renderer.render(document) + + if (asPdf) { + val outputStream = ByteArrayOutputStream() + val baseUri = mdFile.parentFile.toURI().toString() + + // Wrap HTML with proper structure for PDF conversion + val fullHtml = """ @@ -236,80 +325,80 @@ abstract class FileServlet : HttpServlet() { """.trimIndent() - PdfRendererBuilder() - .withHtmlContent(fullHtml, baseUri) - .toStream(outputStream) - .run() + PdfRendererBuilder() + .withHtmlContent(fullHtml, baseUri) + .toStream(outputStream) + .run() - val byteArray = outputStream.toByteArray() - resp.contentType = "application/pdf" - resp.status = HttpServletResponse.SC_OK - resp.outputStream.write(byteArray) - } else { - resp.contentType = "text/html" - resp.characterEncoding = "UTF-8" - resp.status = HttpServletResponse.SC_OK - resp.writer.write(html) - } - } catch (e: Exception) { - log.error("Error rendering markdown file: ${mdFile.absolutePath}", e) - resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR - resp.writer.write("Error rendering markdown: ${e.message}") + val byteArray = outputStream.toByteArray() + resp.contentType = "application/pdf" + resp.status = HttpServletResponse.SC_OK + resp.outputStream.write(byteArray) + } else { + resp.contentType = "text/html" + resp.characterEncoding = "UTF-8" + resp.status = HttpServletResponse.SC_OK + resp.writer.write(html) + } + } catch (e: Exception) { + log.error("Error rendering markdown file: ${mdFile.absolutePath}", e) + resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR + resp.writer.write("Error rendering markdown: ${e.message}") + } } - } - private fun getMimeType(fileName: String): String { - return when { - fileName.endsWith(".js") -> "application/javascript" - fileName.endsWith(".mjs") -> "application/javascript" - fileName.endsWith(".log") -> "text/plain" - else -> MimeTypes.getDefaultMimeByExtension(fileName) ?: "application/octet-stream" + private fun getMimeType(fileName: String): String { + return when { + fileName.endsWith(".js") -> "application/javascript" + fileName.endsWith(".mjs") -> "application/javascript" + fileName.endsWith(".log") -> "text/plain" + else -> MimeTypes.getDefaultMimeByExtension(fileName) ?: "application/octet-stream" + } } - } - open fun getZipLink( - req: HttpServletRequest, - filePath: String - ): String = "" + open fun getZipLink( + req: HttpServletRequest, + filePath: String + ): String = "" - private fun generateBreadcrumbs(currentPath: String, servletBaseHref: String): String { - val parts = currentPath.split("/").filter { it.isNotEmpty() } - val breadcrumbs = StringBuilder() - val rootLink = if (servletBaseHref.endsWith("/")) servletBaseHref else "$servletBaseHref/" + private fun generateBreadcrumbs(currentPath: String, servletBaseHref: String): String { + val parts = currentPath.split("/").filter { it.isNotEmpty() } + val breadcrumbs = StringBuilder() + val rootLink = if (servletBaseHref.endsWith("/")) servletBaseHref else "$servletBaseHref/" - // Root breadcrumb - if (parts.isEmpty()) { - breadcrumbs.append("""""") - } else { - breadcrumbs.append("""""") - } + // Root breadcrumb + if (parts.isEmpty()) { + breadcrumbs.append("""""") + } else { + breadcrumbs.append("""""") + } + + var accumulatedPath = "" + for ((index, part) in parts.withIndex()) { + accumulatedPath += "$part/" + // Separator + if (index >= 0) { // Always add separator if there are parts after Root + breadcrumbs.append("""
  • /
  • """) + } - var accumulatedPath = "" - for ((index, part) in parts.withIndex()) { - accumulatedPath += "$part/" - // Separator - if (index >= 0) { // Always add separator if there are parts after Root - breadcrumbs.append("""
  • /
  • """) - } - - if (index < parts.size - 1) { - breadcrumbs.append("""""") - } else { - breadcrumbs.append("""""") - } + if (index < parts.size - 1) { + breadcrumbs.append("""""") + } else { + breadcrumbs.append("""""") + } + } + return breadcrumbs.toString() } - return breadcrumbs.toString() - } - - private fun directoryHTML( - currentPath: String, - servletBaseHref: String, - zipLink: String, - folders: String, - files: String - ) = """ + + private fun directoryHTML( + currentPath: String, + servletBaseHref: String, + zipLink: String, + folders: String, + files: String + ) = """ | | | @@ -357,6 +446,83 @@ abstract class FileServlet : HttpServlet() { | .zip-link:hover { | background-color: #0b5ed7; /* Darker blue on hover */ | } + | .upload-section { + | background-color: #ffffff; + | border: 1px solid #dee2e6; + | border-radius: 0.375rem; + | margin-bottom: 1.5rem; + | box-shadow: 0 1px 3px rgba(0,0,0,0.03); + | } + | .upload-form { + | display: flex; + | gap: 0.75rem; + | align-items: center; + | flex-wrap: wrap; + | } + | .file-input { + | flex: 1; + | min-width: 200px; + | padding: 0.5rem; + | border: 1px solid #ced4da; + | border-radius: 0.25rem; + | font-size: 0.9rem; + | } + | .upload-button { + | padding: 0.5rem 1.5rem; + | font-size: 0.9rem; + | font-weight: 500; + | color: #fff; + | background-color: #198754; /* Success green */ + | border: none; + | border-radius: 0.25rem; + | cursor: pointer; + | transition: background-color 0.15s ease-in-out; + | } + | .upload-button:hover { + | background-color: #157347; /* Darker green on hover */ + | } + | .upload-button:disabled { + | background-color: #6c757d; + | cursor: not-allowed; + | } + | .upload-message { + | margin-top: 0.5rem; + | padding: 0.5rem; + | border-radius: 0.25rem; + | font-size: 0.9rem; + | } + | .upload-message.success { + | background-color: #d1e7dd; + | color: #0f5132; + | border: 1px solid #badbcc; + | } +| .upload-message.error { + | background-color: #f8d7da; + | color: #842029; + | border: 1px solid #f5c2c7; + | } + .drop-zone { + border: 2px dashed #ced4da; + border-radius: 0.25rem; + padding: 2rem; + text-align: center; + transition: all 0.3s ease; + cursor: pointer; + background-color: #f8f9fa; + } + .drop-zone.drag-over { + border-color: #0d6efd; + background-color: #e7f1ff; + } + .drop-zone-text { + color: #6c757d; + font-size: 0.95rem; + margin-bottom: 0.5rem; + } + .drop-zone-hint { + color: #adb5bd; + font-size: 0.85rem; + } | .container { | max-width: 960px; | margin: 0 auto; @@ -425,12 +591,125 @@ abstract class FileServlet : HttpServlet() { | color: #495057; /* Neutral icon color */ | } | .item-link:hover .icon { color: #0a58ca; } /* Icon color on hover */ - | .empty-state { +| .empty-state { | color: #6c757d; /* Secondary text color */ | padding: 0.5rem 0.75rem; | font-style: italic; | } - | +| + | | | |