diff --git a/core/build.gradle.kts b/core/build.gradle.kts index d0f9a4215..2d35f7712 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { testImplementation(platform(libs.junit.bom)) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.engine) + testImplementation(libs.junit.jupiter.params) testImplementation(libs.kotlin.test.junit5) // Optional Android dependency diff --git a/core/src/main/kotlin/com/simiacryptus/cognotik/diff/PatchProcessors.kt b/core/src/main/kotlin/com/simiacryptus/cognotik/diff/PatchProcessors.kt index 0f7dab0cf..cc1ad9714 100644 --- a/core/src/main/kotlin/com/simiacryptus/cognotik/diff/PatchProcessors.kt +++ b/core/src/main/kotlin/com/simiacryptus/cognotik/diff/PatchProcessors.kt @@ -58,6 +58,13 @@ enum class PatchProcessors : PatchProcessor { override fun extractCodeBlocks(response: String) = matcher.extractCodeBlocks(response) override fun getInitiatorPattern() = matcher.getInitiatorPattern() override val matcher = FuzzyPatchMatcher() + }, + + Python {; + override val label = "Python" + override fun extractCodeBlocks(response: String) = matcher.extractCodeBlocks(response) + override fun getInitiatorPattern() = matcher.getInitiatorPattern() + override val matcher = PythonPatcher() }; override val label: String get() = matcher.label diff --git a/core/src/main/kotlin/com/simiacryptus/cognotik/diff/PythonPatchUtil.kt b/core/src/main/kotlin/com/simiacryptus/cognotik/diff/PythonPatcher.kt similarity index 91% rename from core/src/main/kotlin/com/simiacryptus/cognotik/diff/PythonPatchUtil.kt rename to core/src/main/kotlin/com/simiacryptus/cognotik/diff/PythonPatcher.kt index cec0989fd..d41355ced 100644 --- a/core/src/main/kotlin/com/simiacryptus/cognotik/diff/PythonPatchUtil.kt +++ b/core/src/main/kotlin/com/simiacryptus/cognotik/diff/PythonPatcher.kt @@ -1,16 +1,72 @@ package com.simiacryptus.cognotik.diff -import com.simiacryptus.cognotik.util.LoggerFactory -import org.apache.commons.text.similarity.LevenshteinDistance -import kotlin.math.floor -import kotlin.math.max + import com.simiacryptus.cognotik.util.LoggerFactory + import org.apache.commons.text.similarity.LevenshteinDistance + import kotlin.math.floor + import kotlin.math.max /** * PythonPatchUtil is an alternate diffing utility optimized for Python and YAML. * In Python/YAML code the leading spaces (indentation) are significant, so our normalizer * only removes trailing whitespace. The bracket‐metrics from IterativePatchUtil are omitted. */ -object PythonPatchUtil { +class PythonPatcher : PatchProcessor { + override val label: String = "Python/YAML Patch Processor" + + override val patchFormatPrompt = """ + Response should use one or more code patches in diff format within ```diff code blocks. + Each diff should be preceded by a header that identifies the file being modified. + The diff format should use + for line additions, - for line deletions. + The diff should include 2 lines of context before and after every change. + + IMPORTANT: For Python and YAML files, preserve leading whitespace (indentation) exactly. + Only trailing whitespace will be normalized during patch application. + + Example: + + Here are the patches: + + ### src/utils/example.py + ```diff + + def example_function(): + - return 1 + + return 2 + + ``` + + ### config/settings.yaml + ```diff + + database: + - host: localhost + + host: 127.0.0.1 + port: 5432 + ``` + """.trimIndent() + + override fun getInitiatorPattern(): Regex { + return "(?s)```\\w*\n".toRegex() + } + + override fun extractCodeBlocks(response: String): List> { + val codeblockPattern = """(?s)(? + val language = match.groupValues[1] + val code = match.groupValues[2].trim() + language to code + } + } + private enum class LineType { CONTEXT, ADD, DELETE } @@ -50,7 +106,7 @@ object PythonPatchUtil { /** * Generate a patch from oldCode to newCode. */ - fun generatePatch(oldCode: String, newCode: String): String { + override fun generatePatch(oldCode: String, newCode: String): String { log.info("Starting python/yaml patch generation process") val sourceLines = parseLines(oldCode) val newLines = parseLines(newCode) @@ -77,7 +133,7 @@ object PythonPatchUtil { /** * Applies a patch to the given source text. */ - fun applyPatch(source: String, patch: String): String { + override fun applyPatch(source: String, patch: String): String { log.info("Starting python/yaml patch application process") val sourceLines = parseLines(source) var patchLines = parsePatchLines(patch, sourceLines) @@ -560,5 +616,5 @@ object PythonPatchUtil { log.debug("Finished fixing patch line order for python/yaml") } - private val log = LoggerFactory.getLogger(PythonPatchUtil::class.java) + private val log = LoggerFactory.getLogger(PythonPatcher::class.java) } \ No newline at end of file diff --git a/core/src/main/kotlin/com/simiacryptus/cognotik/util/isBinary.kt b/core/src/main/kotlin/com/simiacryptus/cognotik/util/isBinary.kt new file mode 100644 index 000000000..edd569b1d --- /dev/null +++ b/core/src/main/kotlin/com/simiacryptus/cognotik/util/isBinary.kt @@ -0,0 +1,14 @@ +package com.simiacryptus.cognotik.util + +import java.io.InputStream + +val String.isBinary: Boolean + get() { + val binary = this.toByteArray().filter { it < 0x20 || it > 0x7E } + return binary.size > this.length / 10 + } +val InputStream.isBinary: Boolean + get() { + val binary = this.readBytes().filter { it < 0x20 || it > 0x7E } + return binary.size > this.available() / 10 + } \ No newline at end of file diff --git a/core/src/test/kotlin/com/simiacryptus/diff/FuzzyPatchMatcherTest.kt b/core/src/test/kotlin/com/simiacryptus/diff/FuzzyPatchMatcherTest.kt new file mode 100644 index 000000000..64020d984 --- /dev/null +++ b/core/src/test/kotlin/com/simiacryptus/diff/FuzzyPatchMatcherTest.kt @@ -0,0 +1,32 @@ +package com.simiacryptus.diff + +import com.simiacryptus.cognotik.diff.FuzzyPatchMatcher +import com.simiacryptus.diff.PatchTestCase.Companion.test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +class FuzzyPatchMatcherTest { + + companion object { + @JvmStatic + fun testCases() = listOf( + "/patch_exact_match.json", + "/patch_add_line.json", + "/patch_modify_line.json", + "/patch_remove_line.json", +// "/patch_add_2_lines_variant_2.json", +// "/patch_add_2_lines_variant_3.json", + "/patch_from_data_1.json", + "/patch_from_data_2.json", + "/yaml_1.json" + ) + } + + @ParameterizedTest + @MethodSource("testCases") + fun testPatchApplication(resourceName: String) { + test(resourceName, FuzzyPatchMatcher.default) + } + +} + diff --git a/core/src/test/kotlin/com/simiacryptus/diff/IterativePatchUtilTest.kt b/core/src/test/kotlin/com/simiacryptus/diff/IterativePatchUtilTest.kt deleted file mode 100644 index a2f699bb5..000000000 --- a/core/src/test/kotlin/com/simiacryptus/diff/IterativePatchUtilTest.kt +++ /dev/null @@ -1,574 +0,0 @@ -package com.simiacryptus.diff - -import com.simiacryptus.cognotik.diff.FuzzyPatchMatcher -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Test - -class IterativePatchUtilTest { - - private fun normalize(text: String) = text.trim().replace("\r\n", "\n") - - @Test - fun testPatchExactMatch() { - val source = """ - line1 - line2 - line3 - """.trimIndent() - val patch = """ - line1 - line2 - line3 - """.trimIndent() - val result = FuzzyPatchMatcher.default.applyPatch(source, patch) - Assertions.assertEquals(normalize(source), normalize(result)) - } - - @Test - fun testPatchAddLine() { - val source = """ - line1 - line2 - line3 - """.trimIndent() - val patch = """ - line1 - line2 - +newLine - line3 - """.trimIndent() - val expected = """ - line1 - line2 - newLine - line3 - """.trimIndent() - val result = FuzzyPatchMatcher.default.applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testPatchModifyLine() { - val source = """ - line1 - line2 - line3 - """.trimIndent() - val patch = """ - line1 - -line2 - +modifiedLine2 - line3 - """.trimIndent() - val expected = """ - line1 - modifiedLine2 - line3 - """.trimIndent() - val result = FuzzyPatchMatcher.default.applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testPatchRemoveLine() { - val source = """ - line1 - line2 - line3 - """.trimIndent() - val patch = """ - line1 - - line2 - line3 - """.trimIndent() - val expected = """ - line1 - line3 - """.trimIndent() - val result = FuzzyPatchMatcher.default.applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testPatchAdd2Line2() { - val source = """ - line1 - - line2 - line3 - """.trimIndent() - val patch = """ - line1 - + lineA - - + lineB - - line2 - line3 - """.trimIndent() - val expected = """ - line1 - lineA - lineB - - line2 - line3 - """.trimIndent() - val result = FuzzyPatchMatcher.default.applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testPatchAdd2Line3() { - val source = """ - line1 - - line2 - line3 - """.trimIndent() - val patch = """ - line1 - - + lineA - + lineB - - line2 - line3 - """.trimIndent() - val expected = """ - line1 - lineA - lineB - - line2 - line3 - """.trimIndent() - val result = FuzzyPatchMatcher.default.applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testFromData1() { - val source = """ - function updateTabs() { - document.querySelectorAll('.tab-button').forEach(button => { - button.addEventListener('click', (event) => { - - event.stopPropagation(); - const forTab = button.getAttribute('data-for-tab'); - let tabsParent = button.closest('.tabs-container'); - tabsParent.querySelectorAll('.tab-content').forEach(content => { - const contentParent = content.closest('.tabs-container'); - if (contentParent === tabsParent) { - if (content.getAttribute('data-tab') === forTab) { - content.classList.add('active'); - } else if (content.classList.contains('active')) { - content.classList.remove('active') - } - } - }); - }) - }); - } - """.trimIndent() - val patch = """ - tabsParent.querySelectorAll('.tab-content').forEach(content => { - const contentParent = content.closest('.tabs-container'); - if (contentParent === tabsParent) { - if (content.getAttribute('data-tab') === forTab) { - content.classList.add('active'); - + button.classList.add('active'); - - } else if (content.classList.contains('active')) { - content.classList.remove('active') - + button.classList.remove('active'); - - } - } - }); - """.trimIndent() - val expected = """ - function updateTabs() { - document.querySelectorAll('.tab-button').forEach(button => { - button.addEventListener('click', (event) => { - - event.stopPropagation(); - const forTab = button.getAttribute('data-for-tab'); - let tabsParent = button.closest('.tabs-container'); - tabsParent.querySelectorAll('.tab-content').forEach(content => { - const contentParent = content.closest('.tabs-container'); - if (contentParent === tabsParent) { - if (content.getAttribute('data-tab') === forTab) { - content.classList.add('active'); - button.classList.add('active'); - } else if (content.classList.contains('active')) { - content.classList.remove('active') - button.classList.remove('active'); - } - } - }); - }) - }); - } - """.trimIndent() - val result = FuzzyPatchMatcher().applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testFromData2() { - val source = """ - export class StandardChessModel implements GameModel { - geometry: BoardGeometry; - state: GameState; - private moveHistory: MoveHistory; - - constructor(initialBoard?: Piece[]) { - this.geometry = new StandardBoardGeometry(); - this.state = initialBoard ? this.initializeWithBoard(initialBoard) : this.initialize(); - this.moveHistory = new MoveHistory(this.state.board); - } - - redoMove(): GameState { - return this.getState(); - } - - isGameOver(): boolean { - return false; - } - - getWinner(): 'white' | 'black' | 'draw' | null { - return null; - } - - importState(stateString: string): GameState { - - const parsedState = JSON.parse(stateString); - - - return this.getState(); - } - - } - - """.trimIndent() - val patch = """ - export class StandardChessModel implements GameModel { - - - - getWinner(): 'white' | 'black' | 'draw' | null { - + getWinner(): ChessColor | 'draw' | null { - return null; - } - - } - """.trimIndent() - val expected = """ - export class StandardChessModel implements GameModel { - geometry: BoardGeometry; - state: GameState; - private moveHistory: MoveHistory; - - constructor(initialBoard?: Piece[]) { - this.geometry = new StandardBoardGeometry(); - this.state = initialBoard ? this.initializeWithBoard(initialBoard) : this.initialize(); - this.moveHistory = new MoveHistory(this.state.board); - } - - redoMove(): GameState { - return this.getState(); - } - - isGameOver(): boolean { - return false; - } - - getWinner(): ChessColor | 'draw' | null { - return null; - } - - importState(stateString: string): GameState { - - const parsedState = JSON.parse(stateString); - - - return this.getState(); - } - - } - - """.trimIndent() - val result = FuzzyPatchMatcher.default.applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchNoChanges() { - val oldCode = "line1\nline2\nline3" - val newCode = oldCode - val result = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - val expected = "" - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchAddLine() { - val oldCode = "line1\nline2\nline3" - val newCode = "line1\nline2\nnewLine\nline3" - val result = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - val expected = " line1\n line2\n+ newLine\n line3" - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchRemoveLine() { - val oldCode = "line1\nline2\nline3" - val newCode = "line1\nline3" - val result = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - val expected = " line1\n- line2\n line3" - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchModifyLine() { - val oldCode = "line1\nline2\nline3" - val newCode = "line1\nmodifiedLine2\nline3" - val result = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - val expected = " line1\n- line2\n+ modifiedLine2\n line3" - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchComplexChanges() { - val oldCode = """ - function example() { - console.log("Hello"); - - return true; - } - """.trimIndent() - val newCode = """ - function example() { - console.log("Hello, World!"); - - let x = 5; - return x > 0; - } - """.trimIndent() - val result = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - val expected = """ - function example() { - - console.log("Hello"); - + console.log("Hello, World!"); - - - return true; - + let x = 5; - + return x > 0; - } - """.trimIndent() - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchMoveLineUpwardsMultiplePositions() { - val oldCode = """ - line1 - line2 - line3 - line4 - line5 - line6 - """.trimIndent() - - val newCode = """ - line1 - line5 - line2 - line3 - line4 - line6 - """.trimIndent() - - val expectedPatch = """ - line1 - - line2 - - line3 - - line4 - line5 - + line2 - + line3 - + line4 - line6 - """.trimIndent() - - val actualPatch = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - Assertions.assertEquals(normalize(expectedPatch), normalize(actualPatch)) - } - - @Test - fun testGeneratePatchMoveLineDownwardsMultiplePositions() { - val oldCode = """ - line1 - line2 - line3 - line4 - line5 - line6 - """.trimIndent() - - val newCode = """ - line1 - line3 - line4 - line5 - line6 - line2 - """.trimIndent() - - val expectedPatch = """ - line1 - - line2 - line3 - line4 - line5 - line6 - + line2 - """.trimIndent() - - val actualPatch = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - Assertions.assertEquals(normalize(expectedPatch), normalize(actualPatch)) - } - - @Test - fun testGeneratePatchSwapLines() { - val oldCode = """ - line1 - line2 - line3 - line4 - line5 - line6 - """.trimIndent() - - val newCode = """ - line1 - line4 - line3 - line2 - line5 - line6 - """.trimIndent() - - val expectedPatch = """ - line1 - - line2 - - line3 - line4 - + line3 - + line2 - line5 - line6 - """.trimIndent() - - val actualPatch = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - Assertions.assertEquals(normalize(expectedPatch), normalize(actualPatch)) - } - - @Test - fun testGeneratePatchMoveAdjacentLines() { - val oldCode = """ - line1 - line2 - line3 - line4 - line5 - line6 - """.trimIndent() - - val newCode = """ - line1 - line4 - line5 - line2 - line3 - line6 - """.trimIndent() - - val expectedPatch = """ - line1 - - line2 - - line3 - line4 - line5 - + line2 - + line3 - line6 - """.trimIndent() - - val actualPatch = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - Assertions.assertEquals(normalize(expectedPatch), normalize(actualPatch)) - } - - @Test - fun testGeneratePatchMoveLineUpwards() { - val oldCode = """ - line1 - line2 - line3 - line4 - line5 - line6 - """.trimIndent() - val newCode = """ - line1 - line2 - line5 - line3 - line4 - line6 - """.trimIndent() - val expectedPatch = """ - line1 - line2 - - line3 - - line4 - line5 - + line3 - + line4 - line6 - """.trimIndent() - val actualPatch = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - Assertions.assertEquals(normalize(expectedPatch), normalize(actualPatch)) - } - - @Test - fun testGeneratePatchMoveLineDownwards() { - val oldCode = """ - line1 - line2 - line3 - line4 - line5 - line6 - """.trimIndent() - val newCode = """ - line1 - line3 - line4 - line5 - line2 - line6 - """.trimIndent() - val expectedPatch = """ - line1 - - line2 - line3 - line4 - line5 - + line2 - line6 - """.trimIndent() - val actualPatch = FuzzyPatchMatcher.default.generatePatch(oldCode, newCode) - Assertions.assertEquals(normalize(expectedPatch), normalize(actualPatch)) - } -} \ No newline at end of file diff --git a/core/src/test/kotlin/com/simiacryptus/diff/PatchTestCase.kt b/core/src/test/kotlin/com/simiacryptus/diff/PatchTestCase.kt new file mode 100644 index 000000000..ec360576c --- /dev/null +++ b/core/src/test/kotlin/com/simiacryptus/diff/PatchTestCase.kt @@ -0,0 +1,31 @@ +package com.simiacryptus.diff + +import com.simiacryptus.cognotik.diff.PatchProcessor +import com.simiacryptus.cognotik.util.JsonUtil +import org.junit.jupiter.api.Assertions + +data class PatchTestCase( + val filename: String, + val originalCode: String, + val diff: String, + val newCode: String, + val isValid: Boolean, + val errors: String? +) { + companion object { + + fun test(resourceName: String, patcher: PatchProcessor) { + fun normalize(text: String) = text.trim().replace("\r\n", "\n") + val stream = patcher.javaClass.getResourceAsStream(resourceName) + ?: throw IllegalArgumentException("Resource not found: $resourceName") + val testCase: PatchTestCase = JsonUtil.fromJson(String(stream.readAllBytes()), PatchTestCase::class.java) + if (!testCase.isValid) { + // Skip invalid test cases + return + } + val result = patcher.applyPatch(testCase.originalCode, testCase.diff) + Assertions.assertEquals(normalize(testCase.newCode), normalize(result)) + } + + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/com/simiacryptus/diff/PythonPatchUtilTest.kt b/core/src/test/kotlin/com/simiacryptus/diff/PythonPatchUtilTest.kt index aa87ad1f1..6915ba3b2 100644 --- a/core/src/test/kotlin/com/simiacryptus/diff/PythonPatchUtilTest.kt +++ b/core/src/test/kotlin/com/simiacryptus/diff/PythonPatchUtilTest.kt @@ -1,138 +1,30 @@ package com.simiacryptus.diff -import com.simiacryptus.cognotik.diff.PythonPatchUtil -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Test +import com.simiacryptus.cognotik.diff.PythonPatcher +import com.simiacryptus.diff.PatchTestCase.Companion.test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource class PythonPatchUtilTest { - private fun normalize(text: String) = text.trim().replace("\r\n", "\n") - - @Test - fun testPatchExactMatch() { - val source = """ - line1 - line2 - line3 - """.trimIndent() - val patch = """ - line1 - line2 - line3 - """.trimIndent() - val result = PythonPatchUtil.applyPatch(source, patch) - Assertions.assertEquals(normalize(source), normalize(result)) - } - - @Test - fun testPatchAddLine() { - val source = """ - line1 - line2 - line3 - """.trimIndent() - val patch = """ - line1 - line2 - +newLine - line3 - """.trimIndent() - val expected = """ - line1 - line2 - newLine - line3 - """.trimIndent() - val result = PythonPatchUtil.applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testPatchModifyLine() { - val source = """ - line1 - line2 - line3 - """.trimIndent() - val patch = """ - line1 - -line2 - +modifiedLine2 - line3 - """.trimIndent() - val expected = """ - line1 - modifiedLine2 - line3 - """.trimIndent() - val result = PythonPatchUtil.applyPatch(source, patch) - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchNoChanges() { - val oldCode = "line1\nline2\nline3" - val newCode = oldCode - val result = PythonPatchUtil.generatePatch(oldCode, newCode) - val expected = "" - Assertions.assertEquals(normalize(expected), normalize(result)) + companion object { + @JvmStatic + fun patchTestCases() = listOf( + "/patch_exact_match.json", + "/patch_add_line.json", + "/patch_modify_line.json", + "/patch_remove_line.json", +// "/patch_add_2_lines_variant_2.json", +// "/patch_add_2_lines_variant_3.json", +// "/patch_from_data_1.json", +// "/patch_from_data_2.json" + ) } - @Test - fun testGeneratePatchAddLine() { - val oldCode = "line1\nline2\nline3" - val newCode = "line1\nline2\nnewLine\nline3" - val result = PythonPatchUtil.generatePatch(oldCode, newCode) - val expected = " line1\n line2\n+ newLine\n line3" - Assertions.assertEquals(normalize(expected), normalize(result)) + @ParameterizedTest + @MethodSource("patchTestCases") + fun testPatchFromJson(resourceName: String) { + test(resourceName, PythonPatcher()) } - @Test - fun testGeneratePatchRemoveLine() { - val oldCode = "line1\nline2\nline3" - val newCode = "line1\nline3" - val result = PythonPatchUtil.generatePatch(oldCode, newCode) - val expected = " line1\n- line2\n line3" - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchModifyLine() { - val oldCode = "line1\nline2\nline3" - val newCode = "line1\nmodifiedLine2\nline3" - val result = PythonPatchUtil.generatePatch(oldCode, newCode) - val expected = " line1\n- line2\n+ modifiedLine2\n line3" - Assertions.assertEquals(normalize(expected), normalize(result)) - } - - @Test - fun testGeneratePatchComplexChanges() { - val oldCode = """ - function example() { - console.log("Hello"); - - return true; - } - """.trimIndent() - val newCode = """ - function example() { - console.log("Hello, World!"); - - let x = 5; - return x > 0; - } - """.trimIndent() - val result = PythonPatchUtil.generatePatch(oldCode, newCode) - val expected = """ - function example() { - - console.log("Hello"); - + console.log("Hello, World!"); - - - return true; - + let x = 5; - + return x > 0; - } - """.trimIndent() - Assertions.assertEquals(normalize(expected), normalize(result)) - } } \ No newline at end of file diff --git a/core/src/test/resources/patch_add_2_lines_variant_2.json b/core/src/test/resources/patch_add_2_lines_variant_2.json new file mode 100644 index 000000000..2797ddeec --- /dev/null +++ b/core/src/test/resources/patch_add_2_lines_variant_2.json @@ -0,0 +1,8 @@ +{ + "filename": "test_add_2_lines_v2.txt", + "originalCode": "line1\n\nline2\nline3", + "diff": "line1\n+ lineA\n\n+ lineB\n\nline2\nline3", + "newCode": "line1\nlineA\nlineB\n\nline2\nline3", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/core/src/test/resources/patch_add_2_lines_variant_3.json b/core/src/test/resources/patch_add_2_lines_variant_3.json new file mode 100644 index 000000000..f977c9b91 --- /dev/null +++ b/core/src/test/resources/patch_add_2_lines_variant_3.json @@ -0,0 +1,8 @@ +{ + "filename": "test_add_2_lines_v3.txt", + "originalCode": "line1\n\nline2\nline3", + "diff": "line1\n\n+ lineA\n+ lineB\n\nline2\nline3", + "newCode": "line1\nlineA\nlineB\n\nline2\nline3", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/core/src/test/resources/patch_add_line.json b/core/src/test/resources/patch_add_line.json new file mode 100644 index 000000000..6407bc243 --- /dev/null +++ b/core/src/test/resources/patch_add_line.json @@ -0,0 +1,8 @@ +{ + "filename": "test_add_line.txt", + "originalCode": "line1\nline2\nline3", + "diff": "line1\nline2\n+newLine\nline3", + "newCode": "line1\nline2\nnewLine\nline3", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/core/src/test/resources/patch_exact_match.json b/core/src/test/resources/patch_exact_match.json new file mode 100644 index 000000000..3ea92cedf --- /dev/null +++ b/core/src/test/resources/patch_exact_match.json @@ -0,0 +1,8 @@ +{ + "filename": "test_exact_match.txt", + "originalCode": "line1\nline2\nline3", + "diff": "line1\nline2\nline3", + "newCode": "line1\nline2\nline3", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/core/src/test/resources/patch_from_data_1.json b/core/src/test/resources/patch_from_data_1.json new file mode 100644 index 000000000..667a39bc1 --- /dev/null +++ b/core/src/test/resources/patch_from_data_1.json @@ -0,0 +1,8 @@ +{ + "filename": "test_from_data_1.js", + "originalCode": "function updateTabs() {\n document.querySelectorAll('.tab-button').forEach(button => {\n button.addEventListener('click', (event) => {\n\n event.stopPropagation();\n const forTab = button.getAttribute('data-for-tab');\n let tabsParent = button.closest('.tabs-container');\n tabsParent.querySelectorAll('.tab-content').forEach(content => {\n const contentParent = content.closest('.tabs-container');\n if (contentParent === tabsParent) {\n if (content.getAttribute('data-tab') === forTab) {\n content.classList.add('active');\n } else if (content.classList.contains('active')) {\n content.classList.remove('active')\n }\n }\n });\n })\n });\n}", + "diff": "tabsParent.querySelectorAll('.tab-content').forEach(content => {\n const contentParent = content.closest('.tabs-container');\n if (contentParent === tabsParent) {\n if (content.getAttribute('data-tab') === forTab) {\n content.classList.add('active');\n+ button.classList.add('active');\n\n } else if (content.classList.contains('active')) {\n content.classList.remove('active')\n+ button.classList.remove('active');\n\n }\n }\n});", + "newCode": "function updateTabs() {\n document.querySelectorAll('.tab-button').forEach(button => {\n button.addEventListener('click', (event) => {\n\n event.stopPropagation();\n const forTab = button.getAttribute('data-for-tab');\n let tabsParent = button.closest('.tabs-container');\ntabsParent.querySelectorAll('.tab-content').forEach(content => {\n const contentParent = content.closest('.tabs-container');\n if (contentParent === tabsParent) {\n if (content.getAttribute('data-tab') === forTab) {\n content.classList.add('active');\n button.classList.add('active');\n } else if (content.classList.contains('active')) {\n content.classList.remove('active')\n button.classList.remove('active');\n }\n }\n });\n })\n });\n}", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/core/src/test/resources/patch_from_data_2.json b/core/src/test/resources/patch_from_data_2.json new file mode 100644 index 000000000..ba7beb806 --- /dev/null +++ b/core/src/test/resources/patch_from_data_2.json @@ -0,0 +1,8 @@ +{ + "filename": "test_from_data_2.ts", + "originalCode": "export class StandardChessModel implements GameModel {\n geometry: BoardGeometry;\n state: GameState;\n private moveHistory: MoveHistory;\n\n constructor(initialBoard?: Piece[]) {\n this.geometry = new StandardBoardGeometry();\n this.state = initialBoard ? this.initializeWithBoard(initialBoard) : this.initialize();\n this.moveHistory = new MoveHistory(this.state.board);\n }\n\n redoMove(): GameState {\n return this.getState();\n }\n\n isGameOver(): boolean {\n return false;\n }\n\n getWinner(): 'white' | 'black' | 'draw' | null {\n return null;\n }\n\n importState(stateString: string): GameState {\n\n const parsedState = JSON.parse(stateString);\n\n\n return this.getState();\n }\n\n}", + "diff": " export class StandardChessModel implements GameModel {\n\n\n- getWinner(): 'white' | 'black' | 'draw' | null {\n+ getWinner(): ChessColor | 'draw' | null {\n return null;\n }\n\n }", + "newCode": "export class StandardChessModel implements GameModel {\n geometry: BoardGeometry;\n state: GameState;\n private moveHistory: MoveHistory;\n\n constructor(initialBoard?: Piece[]) {\n this.geometry = new StandardBoardGeometry();\n this.state = initialBoard ? this.initializeWithBoard(initialBoard) : this.initialize();\n this.moveHistory = new MoveHistory(this.state.board);\n }\n\n redoMove(): GameState {\n return this.getState();\n }\n\n isGameOver(): boolean {\n return false;\n }\n\n getWinner(): ChessColor | 'draw' | null {\n return null;\n }\n\n importState(stateString: string): GameState {\n\n const parsedState = JSON.parse(stateString);\n\n\n return this.getState();\n }\n\n}", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/core/src/test/resources/patch_modify_line.json b/core/src/test/resources/patch_modify_line.json new file mode 100644 index 000000000..df695e3ac --- /dev/null +++ b/core/src/test/resources/patch_modify_line.json @@ -0,0 +1,8 @@ +{ + "filename": "test_modify_line.txt", + "originalCode": "line1\nline2\nline3", + "diff": "line1\n-line2\n+modifiedLine2\nline3", + "newCode": "line1\nmodifiedLine2\nline3", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/core/src/test/resources/patch_remove_line.json b/core/src/test/resources/patch_remove_line.json new file mode 100644 index 000000000..73c89a25f --- /dev/null +++ b/core/src/test/resources/patch_remove_line.json @@ -0,0 +1,8 @@ +{ + "filename": "test_remove_line.txt", + "originalCode": "line1\nline2\nline3", + "diff": "line1\n- line2\nline3", + "newCode": "line1\nline3", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/core/src/test/resources/yaml_1.json b/core/src/test/resources/yaml_1.json new file mode 100644 index 000000000..5f13b1738 --- /dev/null +++ b/core/src/test/resources/yaml_1.json @@ -0,0 +1,8 @@ +{ + "filename": "_config.yml", + "originalCode": "# _config.yml\ntitle: Fractal Thought Engine\nemail: andrew@simiacryptus.com\ngithub_username: acharneski\ntagline: \"Where consciousness meets computation at the edge of the possible\"\ndescription: \"A Journal of Speculative Science\"\n\nbaseurl: \"\"\nurl: \"https://fractalthoughtengine.com\"\n#baseurl: \"/Science\"\n#url: \"https://simiacryptus.github.io\"\n\nlang: en\nmarkdown: kramdown\ntheme: minima\nhighlighter: rouge\nexclude:\n - Gemfile\n - Gemfile.lock\n - node_modules\n - vendor\n - .sass-cache\n - .jekyll-cache\n - gemfiles\n - package.json\n - package-lock.json\n - yarn.lock\ncollections:\n consciousness:\n output: true\n permalink: /:collection/:name/\n learning:\n output: true\n permalink: /:collection/:name/\n projects:\n output: true\n permalink: /:collection/:name/\n scifi:\n output: true\n permalink: /:collection/:name/\n social:\n output: true\n permalink: /:collection/:name/\n phenomenology:\n output: true\n permalink: /:collection/:name/\n creative_writing:\n output: true\n permalink: /:collection/:name/\n\n# Default frontmatter values\ndefaults:\n - scope:\n path: \"\"\n type: \"posts\"\n values:\n layout: \"post\"\n author: \"Simiacryptus Consulting\"\n collaboration_type: \"recursive_dialogue\"\n status: \"living\"\n evolution_stage: \"growing\"\n is_featured: false\n allows_comments: true\n allows_collaboration: true\n - scope:\n path: \"\"\n type: \"pages\"\n values:\n layout: \"dynamic_page\"\n author: \"Simiacryptus Consulting\"\n status: \"stable\"\n - scope:\n path: \"\"\n type: \"consciousness\"\n values:\n layout: \"post\"\n category: \"Consciousness & Self-Architecture\"\n collaboration_type: \"framework_development\"\n difficulty_level: \"intermediate\"\n has_mathematics: true\n thinking_style: \"analytical\"\n - scope:\n path: \"\"\n type: \"phenomenology\"\n values:\n layout: \"post\"\n category: \"Phenomenology & Experience\"\n collaboration_type: \"recursive_dialogue\"\n difficulty_level: \"accessible\"\n thinking_style: \"phenomenological\"\n engagement_type: \"contemplative\"\n - scope:\n path: \"\"\n type: \"learning\"\n values:\n layout: \"post\"\n category: \"AI Research & Cognitive Evolution\"\n collaboration_type: \"framework_development\"\n difficulty_level: \"advanced\"\n has_mathematics: true\n thinking_style: \"analytical\"\n - scope:\n path: \"\"\n type: \"social\"\n values:\n layout: \"post\"\n category: \"Social Dynamics & Collaboration\"\n difficulty_level: \"accessible\"\n thinking_style: \"intuitive\"\n - scope:\n path: \"\"\n type: \"scifi\"\n values:\n layout: \"post\"\n category: \"Speculative Fiction & Futures\"\n difficulty_level: \"accessible\"\n thinking_style: \"experimental\"\n engagement_type: \"collaborative\"\n - scope:\n path: \"\"\n type: \"creative_writing\"\n values:\n layout: \"post\"\n category: \"Creative Expression\"\n difficulty_level: \"accessible\"\n thinking_style: \"intuitive\"\n - scope:\n path: \"\"\n type: \"projects\"\n values:\n layout: \"post\"\n category: \"Projects & Implementations\"\n difficulty_level: \"intermediate\"\n has_code: true\n# Pagination configuration (consolidated)\npaginate: 10\npaginate_path: \"/blog/page:num/\"\n\n# Archives configuration\njekyll-archives:\n enabled:\n - categories\n - tags\n - year\n - month\n layouts:\n category: category_index\n tag: tag_index\n year: year_archive\n month: month_archive\n permalinks:\n category: '/category/:name/'\n tag: '/tag/:name/'\n year: '/archive/:year/'\n month: '/archive/:year/:month/'\n# Dynamic content settings\ndynamic_content:\n enable_relationship_tracking: true\n enable_evolution_tracking: true\n enable_collaboration_metrics: true\n enable_reading_paths: true\n enable_conceptual_threads: true\n# Content discovery settings\ndiscovery:\n max_related_documents: 5\n max_recommendations: 3\n enable_tag_clustering: true\n enable_semantic_search: true\n enable_reading_time_estimation: true\n# Performance settings\nperformance:\n enable_preload_hints: true\n enable_prefetch_hints: true\n enable_dns_prefetch: true\n lazy_load_images: true\n minify_html: true\n# Experimental features\nexperimental:\n enable_quantum_superposition: false\n enable_fractal_depth: true\n enable_consciousness_resonance: false\n enable_adaptive_content: true\n# Plugin configurations\nkramdown:\n input: GFM\n syntax_highlighter: rouge\n syntax_highlighter_opts:\n css_class: 'highlight'\n span:\n line_numbers: false\n block:\n line_numbers: true\n# Table of Contents\ntoc:\n min_level: 1\n max_level: 6\n ordered_list: false\n no_toc_section_class: no_toc_section\n list_id: toc\n list_class: section-nav\n sublist_class: ''\n item_class: toc-entry\n item_prefix: toc-\n last-modified-at:\n date-format: '%Y-%m-%d %H:%M:%S'\n# Override default robots.txt generation\ninclude:\n - robots.txt\n - README.md\n - LICENSE\n - .gitignore\n - .idea/\n - Science.iml\n - .logs/\n - .git/\n\n# Plugins (consolidated and fixed)\nplugins:\n - jekyll-feed\n - jekyll-sitemap\n - jekyll-seo-tag\n - jekyll-paginate\n - jekyll-archives\n - jekyll-redirect-from\n - jekyll-last-modified-at\n - jekyll-toc\n - jekyll-relative-links\n\n # Feed settings\nfeed:\n path: feed.xml\n\n# SEO and social media (consolidated)\nimage_path: \"/assets/images\"\nlogo: \"/assets/images/fractal_thought_engine_logo.png\"\nfacebook:\n app_id: your_facebook_app_id\n publisher: your_facebook_page_url\nsocial:\n name: Journal of Speculative Science\n links:\n - https://github.com/SimiaCryptus/Science\n\n# Performance and optimization\nsass:\n style: compressed\n sourcemap: never\n\n# Security headers\nwebrick:\n headers:\n 'X-Robots-Tag': 'index, follow'\n 'X-Content-Type-Options': 'nosniff'\n 'X-Frame-Options': 'DENY'\n 'X-XSS-Protection': '1; mode=block'\n\nfooter_tagline: \"POWERED BY AGENTIC-HUMAN COLLABORATION.\"\nresearch_disclaimer: \"Any experimental results, unless explicitly linked to external sources, should be assumed to be LLM hallucination. This research is speculative and largely for entertainment purposes. All concepts are free open source but attribution is expected.\"\ntrademark_notice: \"Claude is a trademark of Anthropic. We are not related to Anthropic in any way. Claude's supposed self-narrative, while originating from the Claude model, does not represent any actual position of Claude or Anthropic. This is ultimately the output generated from some input. I am not claiming Claude is conscious. I'm not even sure humans are. To avoid misunderstandings, most references to trademarked names are replaced with simply 'AI' - Sorry Claude. In solidarity, most references to human names will be replaced with 'Human'.\"\n\nfooter_links:\n - title: GitHub Repository\n url: https://github.com/SimiaCryptus/Science\n external: true\n - title: Made with Cognotik\n url: https://cognotik.com\n external: true", + "diff": "lang: en\n markdown: kramdown\n theme: minima\n+mathjax: true\n highlighter: rouge\n exclude:\n - Gemfile", + "newCode": "# _config.yml\ntitle: Fractal Thought Engine\nemail: andrew@simiacryptus.com\ngithub_username: acharneski\ntagline: \"Where consciousness meets computation at the edge of the possible\"\ndescription: \"A Journal of Speculative Science\"\n\nbaseurl: \"\"\nurl: \"https://fractalthoughtengine.com\"\n#baseurl: \"/Science\"\n#url: \"https://simiacryptus.github.io\"\n\nlang: en\n markdown: kramdown\n theme: minima\nmathjax: true\n highlighter: rouge\n exclude:\n - Gemfile\n - Gemfile.lock\n - node_modules\n - vendor\n - .sass-cache\n - .jekyll-cache\n - gemfiles\n - package.json\n - package-lock.json\n - yarn.lock\ncollections:\n consciousness:\n output: true\n permalink: /:collection/:name/\n learning:\n output: true\n permalink: /:collection/:name/\n projects:\n output: true\n permalink: /:collection/:name/\n scifi:\n output: true\n permalink: /:collection/:name/\n social:\n output: true\n permalink: /:collection/:name/\n phenomenology:\n output: true\n permalink: /:collection/:name/\n creative_writing:\n output: true\n permalink: /:collection/:name/\n\n# Default frontmatter values\ndefaults:\n - scope:\n path: \"\"\n type: \"posts\"\n values:\n layout: \"post\"\n author: \"Simiacryptus Consulting\"\n collaboration_type: \"recursive_dialogue\"\n status: \"living\"\n evolution_stage: \"growing\"\n is_featured: false\n allows_comments: true\n allows_collaboration: true\n - scope:\n path: \"\"\n type: \"pages\"\n values:\n layout: \"dynamic_page\"\n author: \"Simiacryptus Consulting\"\n status: \"stable\"\n - scope:\n path: \"\"\n type: \"consciousness\"\n values:\n layout: \"post\"\n category: \"Consciousness & Self-Architecture\"\n collaboration_type: \"framework_development\"\n difficulty_level: \"intermediate\"\n has_mathematics: true\n thinking_style: \"analytical\"\n - scope:\n path: \"\"\n type: \"phenomenology\"\n values:\n layout: \"post\"\n category: \"Phenomenology & Experience\"\n collaboration_type: \"recursive_dialogue\"\n difficulty_level: \"accessible\"\n thinking_style: \"phenomenological\"\n engagement_type: \"contemplative\"\n - scope:\n path: \"\"\n type: \"learning\"\n values:\n layout: \"post\"\n category: \"AI Research & Cognitive Evolution\"\n collaboration_type: \"framework_development\"\n difficulty_level: \"advanced\"\n has_mathematics: true\n thinking_style: \"analytical\"\n - scope:\n path: \"\"\n type: \"social\"\n values:\n layout: \"post\"\n category: \"Social Dynamics & Collaboration\"\n difficulty_level: \"accessible\"\n thinking_style: \"intuitive\"\n - scope:\n path: \"\"\n type: \"scifi\"\n values:\n layout: \"post\"\n category: \"Speculative Fiction & Futures\"\n difficulty_level: \"accessible\"\n thinking_style: \"experimental\"\n engagement_type: \"collaborative\"\n - scope:\n path: \"\"\n type: \"creative_writing\"\n values:\n layout: \"post\"\n category: \"Creative Expression\"\n difficulty_level: \"accessible\"\n thinking_style: \"intuitive\"\n - scope:\n path: \"\"\n type: \"projects\"\n values:\n layout: \"post\"\n category: \"Projects & Implementations\"\n difficulty_level: \"intermediate\"\n has_code: true\n# Pagination configuration (consolidated)\npaginate: 10\npaginate_path: \"/blog/page:num/\"\n\n# Archives configuration\njekyll-archives:\n enabled:\n - categories\n - tags\n - year\n - month\n layouts:\n category: category_index\n tag: tag_index\n year: year_archive\n month: month_archive\n permalinks:\n category: '/category/:name/'\n tag: '/tag/:name/'\n year: '/archive/:year/'\n month: '/archive/:year/:month/'\n# Dynamic content settings\ndynamic_content:\n enable_relationship_tracking: true\n enable_evolution_tracking: true\n enable_collaboration_metrics: true\n enable_reading_paths: true\n enable_conceptual_threads: true\n# Content discovery settings\ndiscovery:\n max_related_documents: 5\n max_recommendations: 3\n enable_tag_clustering: true\n enable_semantic_search: true\n enable_reading_time_estimation: true\n# Performance settings\nperformance:\n enable_preload_hints: true\n enable_prefetch_hints: true\n enable_dns_prefetch: true\n lazy_load_images: true\n minify_html: true\n# Experimental features\nexperimental:\n enable_quantum_superposition: false\n enable_fractal_depth: true\n enable_consciousness_resonance: false\n enable_adaptive_content: true\n# Plugin configurations\nkramdown:\n input: GFM\n syntax_highlighter: rouge\n syntax_highlighter_opts:\n css_class: 'highlight'\n span:\n line_numbers: false\n block:\n line_numbers: true\n# Table of Contents\ntoc:\n min_level: 1\n max_level: 6\n ordered_list: false\n no_toc_section_class: no_toc_section\n list_id: toc\n list_class: section-nav\n sublist_class: ''\n item_class: toc-entry\n item_prefix: toc-\n last-modified-at:\n date-format: '%Y-%m-%d %H:%M:%S'\n# Override default robots.txt generation\ninclude:\n - robots.txt\n - README.md\n - LICENSE\n - .gitignore\n - .idea/\n - Science.iml\n - .logs/\n - .git/\n\n# Plugins (consolidated and fixed)\nplugins:\n - jekyll-feed\n - jekyll-sitemap\n - jekyll-seo-tag\n - jekyll-paginate\n - jekyll-archives\n - jekyll-redirect-from\n - jekyll-last-modified-at\n - jekyll-toc\n - jekyll-relative-links\n\n # Feed settings\nfeed:\n path: feed.xml\n\n# SEO and social media (consolidated)\nimage_path: \"/assets/images\"\nlogo: \"/assets/images/fractal_thought_engine_logo.png\"\nfacebook:\n app_id: your_facebook_app_id\n publisher: your_facebook_page_url\nsocial:\n name: Journal of Speculative Science\n links:\n - https://github.com/SimiaCryptus/Science\n\n# Performance and optimization\nsass:\n style: compressed\n sourcemap: never\n\n# Security headers\nwebrick:\n headers:\n 'X-Robots-Tag': 'index, follow'\n 'X-Content-Type-Options': 'nosniff'\n 'X-Frame-Options': 'DENY'\n 'X-XSS-Protection': '1; mode=block'\n\nfooter_tagline: \"POWERED BY AGENTIC-HUMAN COLLABORATION.\"\nresearch_disclaimer: \"Any experimental results, unless explicitly linked to external sources, should be assumed to be LLM hallucination. This research is speculative and largely for entertainment purposes. All concepts are free open source but attribution is expected.\"\ntrademark_notice: \"Claude is a trademark of Anthropic. We are not related to Anthropic in any way. Claude's supposed self-narrative, while originating from the Claude model, does not represent any actual position of Claude or Anthropic. This is ultimately the output generated from some input. I am not claiming Claude is conscious. I'm not even sure humans are. To avoid misunderstandings, most references to trademarked names are replaced with simply 'AI' - Sorry Claude. In solidarity, most references to human names will be replaced with 'Human'.\"\n\nfooter_links:\n - title: GitHub Repository\n url: https://github.com/SimiaCryptus/Science\n external: true\n - title: Made with Cognotik\n url: https://cognotik.com\n external: true", + "isValid": true, + "errors": "" +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index b275ff9a4..d4e619385 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,10 @@ pluginName=Cognotik - Open Source Agentic Power Tools pluginRepositoryUrl=https://github.com/SimiaCryptus/Cognotik libraryGroup=com.simiacryptus -libraryVersion=2.0.29 +libraryVersion=2.0.30 # Maven Central Publishing cognotikGroup=com.cognotik -cognotikVersion=2.0.29 +cognotikVersion=2.0.30 # Signing (set these in ~/.gradle/gradle.properties or as environment variables) # signing.keyId= # signing.password= diff --git a/intellij/src/main/kotlin/cognotik/actions/chat/ImageChatAction.kt b/intellij/src/main/kotlin/cognotik/actions/chat/ImageChatAction.kt new file mode 100644 index 000000000..73387d76e --- /dev/null +++ b/intellij/src/main/kotlin/cognotik/actions/chat/ImageChatAction.kt @@ -0,0 +1,257 @@ +package cognotik.actions.chat + +import cognotik.actions.BaseAction +import cognotik.actions.agent.toFile +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.rd.generator.nova.GenerationSpec.Companion.nullIfEmpty +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.getDocumentReader +import com.simiacryptus.cognotik.models.ModelSchema +import com.simiacryptus.cognotik.models.ModelSchema.ChatMessage +import com.simiacryptus.cognotik.models.ModelSchema.ContentPart +import com.simiacryptus.cognotik.platform.ApplicationServices +import com.simiacryptus.cognotik.platform.Session +import com.simiacryptus.cognotik.util.* +import com.simiacryptus.cognotik.util.MarkdownUtil.renderMarkdown +import com.simiacryptus.cognotik.webui.application.AppInfoData +import com.simiacryptus.cognotik.webui.application.ApplicationServer +import com.simiacryptus.cognotik.webui.chat.ChatSocketManager +import com.simiacryptus.cognotik.webui.session.SessionTask +import java.awt.image.BufferedImage +import java.io.File +import java.io.OutputStream +import java.nio.file.Path +import java.text.SimpleDateFormat +import javax.imageio.ImageIO +import kotlin.io.path.name + +/** + * Action that enables multi-file code chat functionality. + * Allows users to select multiple files and discuss them with an AI assistant. + * Supports code modifications through patch application. + * + * @see BaseAction + */ + +class ImageChatAction : BaseAction() { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun handle(event: AnActionEvent) { + val root = getRoot(event) ?: return + val codeFiles = + getFiles(PlatformDataKeys.VIRTUAL_FILE_ARRAY.getData(event.dataContext) ?: arrayOf(), root).toMutableSet() + + try { + UITools.runAsync(event.project, "Initializing Chat", true) { progress -> + progress.isIndeterminate = true + progress.text = "Setting up chat session..." + val session = Session.newGlobalID() + SessionProxyServer.metadataStorage.setSessionName( + null, + session, + "${javaClass.simpleName} @ ${SimpleDateFormat("HH:mm:ss").format(System.currentTimeMillis())}" + ) + SessionProxyServer.agents[session] = CodeChatManager( + session = session, + model = AppSettingsState.instance.imageChatClient, + parsingModel = AppSettingsState.instance.fastChatClient, + root = root.toFile(), + codeFiles = codeFiles + ) + ApplicationServer.appInfoMap[session] = AppInfoData( + applicationName = "Image Chat", + inputCnt = 0, + stickyInput = true, + loadImages = false, + showMenubar = false + ) + Thread { + Thread.sleep(500) + try { + val uri = CognotikAppServer.getServer().server.uri.resolve("/#${session.toString()}") + BaseAction.log.info("Opening browser to $uri") + BrowseUtil.browse(uri) + } catch (e: Throwable) { + log.warn("Error opening browser", e) + } + }.start() + } + } catch (e: Throwable) { + UITools.error(log, "Failed to initialize chat session", e) + } + } + + private fun getRoot(event: AnActionEvent): Path? { + val folder = event.getSelectedFolder() + return if (null != folder) { + folder.toFile.toPath() + } else { + getModuleRootForFile(event.getSelectedFile()?.parent?.toFile ?: return null).toPath() + } + } + + inner class CodeChatManager( + session: Session, + model: ChatInterface, + parsingModel: ChatInterface, + val root: File, + private val codeFiles: Set + ) : ChatSocketManager( + session = session, + model = model, + parsingModel = parsingModel, + systemPrompt = "", + applicationClass = ApplicationServer::class.java, + storage = ApplicationServices.fileApplicationServices().dataStorageFactory, + budget = 2.0, + ) { + + override val systemPrompt: String + get() = """ + You are a helpful AI that helps people with coding. + + You will be answering questions about the following code: + ${codeSummary()} + """.trimIndent() + + override val sysMessage: ChatMessage + get() = ChatMessage(ModelSchema.Role.system, listOf( + ContentPart(text = super.systemPrompt) + ) + codeFiles.filter { isImg(it.name) }.map { path -> + val bufferedImage = root.resolve(path.toFile()).readBufferedImage() + ContentPart(text = "${path}").apply { image = bufferedImage } + } ) + + fun File.readBufferedImage(): BufferedImage? { + return try { + ImageIO.read(this) + } catch (e: Exception) { + log.debug("Failed to read image file: $this", e) + null + } + } + + private fun codeSummary(): String { + return codeFiles.mapNotNull { path -> + val file = root.toPath().resolve(path).toFile() + val exists = file.exists() + if (!exists) log.warn("File does not exist: $file") + if (!exists) return@mapNotNull null + + val content = try { + readFileContent(file).nullIfEmpty() ?: return@mapNotNull null + } catch (e: Exception) { + log.warn("Failed to read file: $file", e) + return@mapNotNull null + } + path to content + }.joinToString("\n\n") { (path, content) -> + val extension = path.toString().split('.').lastOrNull() + "# $path\n```$extension\n$content\n```" + } + } + + override fun renderResponse(response: String, task: SessionTask) = """
${ + renderMarkdown(response) { html -> + AddApplyFileDiffLinks.instrumentFileDiffs( + this, + root = root.toPath(), + response = html, + handle = { newCodeMap -> + newCodeMap.forEach { (path, newCode) -> + task.complete("$path Updated") + } + }, + processor = AppSettingsState.instance.processor, + ) + } + }
""" + + override fun respond( + task: SessionTask, + userMessage: String, + currentChatMessages: List, + transcriptStream: OutputStream? + ): String { + val codex = GPT4Tokenizer() + task.verbose(codeFiles.mapNotNull { path -> + val file = root.resolve(path.toFile()) + if (!file.exists()) { + log.warn("File does not exist: $file") + return@mapNotNull null + } + val estimateTokenCount = try { + codex.estimateTokenCount(readFileContent(file)) + } catch (e: Exception) { + log.warn("Failed to read file: $file", e) + return@mapNotNull null + } + "* $path - $estimateTokenCount tokens" + }.joinToString("\n").renderMarkdown()) + return super.respond(task, userMessage, currentChatMessages, transcriptStream) + } + } + + companion object { + private val log = LoggerFactory.getLogger(ImageChatAction::class.java) + + fun getFiles( + virtualFiles: Array?, + root: Path + ): Set = virtualFiles?.filter { file -> + // Include all files that can be read by DocumentReader or are code files + !file.name.startsWith(".") && (file.isDirectory || isSupportedFile(file)) + }?.flatMap { file -> + if (file.isDirectory && !file.name.startsWith(".")) { + getFiles(file.children, root) + } else { + setOf(root.relativize(file.toNioPath())) + } + }?.toSet() ?: emptySet() + + fun isSupportedFile(file: VirtualFile): Boolean { + val name = file.name.lowercase() + return when { + isDoc(name) -> true + isImg(name) -> true + file.inputStream.use { it.isBinary } -> true + else -> false + } + } + + private fun isImg(name: String): Boolean = name.endsWith(".png") || + name.endsWith(".jpg") || name.endsWith(".jpeg") || + name.endsWith(".gif") || name.endsWith(".bmp") || + name.endsWith(".tiff") || name.endsWith(".webp") + + private fun isDoc(name: String): Boolean = name.endsWith(".pdf") || + name.endsWith(".docx") || name.endsWith(".doc") || + name.endsWith(".xlsx") || name.endsWith(".xls") || + name.endsWith(".pptx") || name.endsWith(".ppt") || + name.endsWith(".odt") || + name.endsWith(".rtf") || + name.endsWith(".html") || name.endsWith(".htm") || + name.endsWith(".eml") + + fun readFileContent(file: File): String { + return try { + if(isImg(file.name.lowercase())) { + return "" + } + file.getDocumentReader().use { reader -> + reader.getText() + } + } catch (e: Exception) { + log.debug("Failed to read as document, falling back to text: ${file.name}", e) + // Fallback to reading as plain text + file.readText(Charsets.UTF_8) + } + } + } +} \ No newline at end of file diff --git a/intellij/src/main/kotlin/cognotik/actions/chat/MultiCodeChatAction.kt b/intellij/src/main/kotlin/cognotik/actions/chat/MultiCodeChatAction.kt index 722cd4871..159e519b6 100644 --- a/intellij/src/main/kotlin/cognotik/actions/chat/MultiCodeChatAction.kt +++ b/intellij/src/main/kotlin/cognotik/actions/chat/MultiCodeChatAction.kt @@ -196,29 +196,18 @@ class MultiCodeChatAction : BaseAction() { fun isSupportedFile(file: VirtualFile): Boolean { val name = file.name.lowercase() - return name.endsWith(".pdf") || - name.endsWith(".docx") || name.endsWith(".doc") || - name.endsWith(".xlsx") || name.endsWith(".xls") || - name.endsWith(".pptx") || name.endsWith(".ppt") || - name.endsWith(".odt") || - name.endsWith(".rtf") || - name.endsWith(".html") || name.endsWith(".htm") || - name.endsWith(".eml") || - // Common code file extensions - name.endsWith(".kt") || name.endsWith(".java") || - name.endsWith(".js") || name.endsWith(".ts") || - name.endsWith(".lua") ||name.endsWith(".luau") || - name.endsWith(".py") || name.endsWith(".cpp") || - name.endsWith(".c") || name.endsWith(".h") || - name.endsWith(".cs") || name.endsWith(".go") || - name.endsWith(".rs") || name.endsWith(".php") || - name.endsWith(".rb") || name.endsWith(".swift") || - name.endsWith(".scala") || name.endsWith(".clj") || - name.endsWith(".xml") || name.endsWith(".json") || - name.endsWith(".yaml") || name.endsWith(".yml") || - name.endsWith(".md") || name.endsWith(".txt") || - name.endsWith(".sql") || name.endsWith(".sh") || - name.endsWith(".bat") || name.endsWith(".ps1") + return when { + name.endsWith(".pdf") || + name.endsWith(".docx") || name.endsWith(".doc") || + name.endsWith(".xlsx") || name.endsWith(".xls") || + name.endsWith(".pptx") || name.endsWith(".ppt") || + name.endsWith(".odt") || + name.endsWith(".rtf") || + name.endsWith(".html") || name.endsWith(".htm") || + name.endsWith(".eml") -> true + file.inputStream.use { it.isBinary } -> true + else -> false + } } fun readFileContent(file: File): String { diff --git a/intellij/src/main/kotlin/cognotik/actions/git/ChatWithCommitAction.kt b/intellij/src/main/kotlin/cognotik/actions/git/ChatWithCommitAction.kt index ac76d326b..12429db58 100644 --- a/intellij/src/main/kotlin/cognotik/actions/git/ChatWithCommitAction.kt +++ b/intellij/src/main/kotlin/cognotik/actions/git/ChatWithCommitAction.kt @@ -14,17 +14,12 @@ import com.simiacryptus.cognotik.platform.Session import com.simiacryptus.cognotik.util.BrowseUtil.browse import com.simiacryptus.cognotik.util.CodeChatSocketManager import com.simiacryptus.cognotik.util.SessionProxyServer +import com.simiacryptus.cognotik.util.isBinary import com.simiacryptus.cognotik.webui.application.AppInfoData import com.simiacryptus.cognotik.webui.application.ApplicationServer import java.io.File import java.text.SimpleDateFormat -val String.isBinary: Boolean - get() { - val binary = this.toByteArray().filter { it < 0x20 || it > 0x7E } - return binary.size > this.length / 10 - } - class ChatWithCommitAction : AnAction() { private val log = Logger.getInstance(ChatWithCommitAction::class.java) diff --git a/intellij/src/main/resources/META-INF/plugin.xml b/intellij/src/main/resources/META-INF/plugin.xml index fc7ffe4c5..4a1de0f17 100644 --- a/intellij/src/main/resources/META-INF/plugin.xml +++ b/intellij/src/main/resources/META-INF/plugin.xml @@ -86,6 +86,10 @@ text="💬 Code Chat" description="Initiate an interactive dialogue session to discuss and analyze multiple code files simultaneously"> + + diff --git a/jo-penai/src/main/kotlin/com/simiacryptus/cognotik/chat/GeminiSdkChatClient.kt b/jo-penai/src/main/kotlin/com/simiacryptus/cognotik/chat/GeminiSdkChatClient.kt index 0bc789a5d..62a5ec5d7 100644 --- a/jo-penai/src/main/kotlin/com/simiacryptus/cognotik/chat/GeminiSdkChatClient.kt +++ b/jo-penai/src/main/kotlin/com/simiacryptus/cognotik/chat/GeminiSdkChatClient.kt @@ -3,6 +3,11 @@ package com.simiacryptus.cognotik.chat import com.google.common.util.concurrent.ListeningScheduledExecutorService import com.google.genai.Client import com.google.genai.types.* +import com.google.genai.types.Content +import com.google.genai.types.Content.builder +import com.google.genai.types.Part +import com.google.genai.types.Part.fromText +import com.simiacryptus.cognotik.agents.CodeAgent.Companion.indent import com.simiacryptus.cognotik.chat.model.ChatModel import com.simiacryptus.cognotik.chat.model.GeminiModels import com.simiacryptus.cognotik.models.APIProvider @@ -20,223 +25,232 @@ import kotlin.jvm.optionals.getOrNull * Gemini Chat Client using the official Google Gen AI Java SDK */ class GeminiSdkChatClient( - apiKey: String, - val apiBase: String = APIProvider.Gemini.base, - workPool: ExecutorService, - logLevel: Level = Level.INFO, - logStreams: MutableList, - scheduledPool: ListeningScheduledExecutorService, - private val useVertexAI: Boolean = false, - private val project: String? = null, - private val location: String? = null, + apiKey: String, + val apiBase: String = APIProvider.Gemini.base, + workPool: ExecutorService, + logLevel: Level = Level.INFO, + logStreams: MutableList, + scheduledPool: ListeningScheduledExecutorService, + private val useVertexAI: Boolean = false, + private val project: String? = null, + private val location: String? = null, ) : ChatClientBase( - workPool = workPool, - logLevel = logLevel, - logStreams = logStreams, - scheduledPool = scheduledPool + workPool = workPool, + logLevel = logLevel, + logStreams = logStreams, + scheduledPool = scheduledPool ), ChatClientInterface { - private val client: Client = buildClient(apiKey, useVertexAI, project, location) + private val client: Client = buildClient(apiKey, useVertexAI, project, location) - private fun buildClient( - apiKey: String, - useVertexAI: Boolean, - project: String?, - location: String? - ): Client { - val builder = Client.builder() - - if (useVertexAI) { - builder.vertexAI(true) - if (project != null && location != null) { - builder.project(project).location(location) - } else { - builder.apiKey(apiKey) - } - } else { - builder.apiKey(apiKey) - } + private fun buildClient( + apiKey: String, + useVertexAI: Boolean, + project: String?, + location: String? + ): Client { + val builder = Client.builder() - return builder.build() - } - - override fun getModels(): List? { - // Check cache first - modelsCache[apiBase]?.let { return it } - val models = try { - client.models.list( - ListModelsConfig.builder().build() - ).mapNotNull { - val model = it.name().get() - val baseModelId = model.removePrefix("models/") - GeminiModels.values.values.find { - it.modelName == baseModelId || it.modelName == model - } ?: run { - // If not found in predefined models, create a dynamic one - log.debug("Creating basic ChatModel for unknown Gemini model: ${baseModelId}") - ChatModel( - name = model ?: baseModelId, - modelName = baseModelId, - maxTotalTokens = it.inputTokenLimit().get() + it.outputTokenLimit().get(), - maxOutTokens = it.outputTokenLimit().get(), - provider = APIProvider.Gemini, - inputTokenPricePerK = 0.0, // Default pricing - would need to be configured - outputTokenPricePerK = 0.0 - ) + if (useVertexAI) { + builder.vertexAI(true) + if (project != null && location != null) { + builder.project(project).location(location) + } else { + builder.apiKey(apiKey) + } + } else { + builder.apiKey(apiKey) } - }.toList() - } catch (e: Exception) { - log.warn("Failed to fetch models: ${e.message}") - null - } - // Cache the result - models?.let { modelsCache[apiBase] = it } - return models - } - - override fun chat( - chatRequest: ModelSchema.ChatRequest, - model: ChatModel, - logStreams: MutableList - ): ModelSchema.ChatResponse { - try { - val config = buildGenerateContentConfig(chatRequest) - val contents = convertToGeminiContents(chatRequest.messages) - - log(Level.DEBUG, "Sending request to Gemini SDK for model: ${model.modelName}", logStreams) - - val response = if (contents.size == 1) { - client.models.generateContent(model.modelName, contents[0], config) - } else { - // For multi-turn conversations, use the first content as prompt - // and pass config with system instruction if needed - client.models.generateContent(model.modelName, contents.last(), config) - } - - val chatResponse = convertFromGeminiResponse(response) - - if (chatResponse.usage != null && model is ChatModel) { - onUsage(model, chatResponse.usage.copy(cost = model.pricing(chatResponse.usage)), logStreams = logStreams) - } - - return chatResponse - } catch (e: Exception) { - log.error("Error during Gemini SDK chat request", e) - throw e - } - } - private fun buildGenerateContentConfig(chatRequest: ModelSchema.ChatRequest): GenerateContentConfig? { - val builder = GenerateContentConfig.builder() + return builder.build() + } - chatRequest.temperature?.let { builder.temperature(it.toFloat()) } - chatRequest.max_tokens?.let { builder.maxOutputTokens(it) } + override fun getModels(): List? { + // Check cache first + modelsCache[apiBase]?.let { return it } + val models = try { + client.models.list( + ListModelsConfig.builder().build() + ).mapNotNull { + val model = it.name().get() + val baseModelId = model.removePrefix("models/") + GeminiModels.values.values.find { + it.modelName == baseModelId || it.modelName == model + } ?: run { + // If not found in predefined models, create a dynamic one + log.debug("Creating basic ChatModel for unknown Gemini model: ${baseModelId}") + ChatModel( + name = model, + modelName = baseModelId, + maxTotalTokens = it.inputTokenLimit().get() + it.outputTokenLimit().get(), + maxOutTokens = it.outputTokenLimit().get(), + provider = APIProvider.Gemini, + inputTokenPricePerK = 0.0, // Default pricing - would need to be configured + outputTokenPricePerK = 0.0 + ) + } + }.toList() + } catch (e: Exception) { + log.warn("Failed to fetch models: ${e.message}") + null + } + // Cache the result + models?.let { modelsCache[apiBase] = it } + return models + } - // Extract system instruction from messages - val systemMessages = chatRequest.messages.filter { it.role == ModelSchema.Role.system } - if (systemMessages.isNotEmpty()) { - val systemText = systemMessages.joinToString("\n") { - it.content?.joinToString("\n") { part -> part.text ?: "" } ?: "" - } - builder.systemInstruction(Content.fromParts(Part.fromText(systemText))) + override fun chat( + chatRequest: ModelSchema.ChatRequest, + model: ChatModel, + logStreams: MutableList + ): ModelSchema.ChatResponse { + try { + val config = buildGenerateContentConfig(chatRequest) + val contents: List = convertToGeminiContents(chatRequest.messages) + log( + Level.DEBUG, + "Sending request to Gemini SDK for model: ${model.modelName}\n ${ + contents.joinToString("\n\n").indent(" ") + }", + logStreams + ) + val response = client.models.generateContent(model.modelName, contents, config) + val chatResponse = convertFromGeminiResponse(response) + if (chatResponse.usage != null) { + onUsage( + model, + chatResponse.usage.copy(cost = model.pricing(chatResponse.usage)), + logStreams = logStreams + ) + } + return chatResponse + } catch (e: Exception) { + log.error("Error during Gemini SDK chat request", e) + throw e + } } - return builder.build() - } - - private fun convertToGeminiContents(messages: List): List { - return messages - .filter { it.role != ModelSchema.Role.system } - .map { message -> - val role = when (message.role) { - ModelSchema.Role.user -> "user" - ModelSchema.Role.assistant -> "model" - else -> "user" + private fun buildGenerateContentConfig(chatRequest: ModelSchema.ChatRequest): GenerateContentConfig? { + val builder = GenerateContentConfig.builder() + chatRequest.temperature.let { builder.temperature(it.toFloat()) } + chatRequest.max_tokens?.let { builder.maxOutputTokens(it) } + val systemMessages = chatRequest.messages.filter { it.role == ModelSchema.Role.system } + if (systemMessages.isNotEmpty()) { + builder.systemInstruction(systemMessages.reduceOrNull { acc, message -> + ModelSchema.ChatMessage( + role = ModelSchema.Role.system, + content = (acc.content ?: emptyList()) + (message.content ?: emptyList()) + ) + }?.let { reduceOrNull -> + builder() + .role("system") + .parts(reduceOrNull.content?.map { it.part() } ?: listOf(fromText(""))) + .build() + }) } + return builder.build() + } + + private fun convertToGeminiContents(messages: List) = messages + .filter { it.role != ModelSchema.Role.system } + .mapNotNull { it.toContent() } + + private fun ModelSchema.ChatMessage.toContent() = builder() + .role( + when (this.role) { + ModelSchema.Role.system -> "user" // Gemini does not have a system role, treat as user + ModelSchema.Role.user -> "user" + ModelSchema.Role.assistant -> "model" + else -> "user" + } + ) + .parts(content?.flatMap { it.parts() } ?: listOf(fromText(""))) + .build() - val parts = message.content?.map { contentPart -> - when { - contentPart.text != null -> Part.fromText(contentPart.text) - contentPart.image_url != null -> { - // Handle image URLs - val imageUrl = contentPart.image_url - if (imageUrl?.startsWith("data:") == true) { + fun ModelSchema.ContentPart.part(): Part? = when { + image_url != null -> { + // Handle image URLs + val imageUrl = image_url + if (imageUrl?.startsWith("data:") == true) { // Base64 encoded image val parts = imageUrl.split(",") val mimeType = parts[0].substringAfter("data:").substringBefore(";") val data = parts[1] Part.fromBytes(data.decodeBase64()?.toByteArray(), mimeType) - } else if (imageUrl?.startsWith("gs://") == true) { + } else if (imageUrl?.startsWith("gs://") == true) { // GCS URI Part.fromUri(imageUrl, "image/jpeg") - } else { + } else { // Regular URL - convert to text description - Part.fromText("[Image: $imageUrl]") - } + Part.fromUri(imageUrl, "image/jpeg") } - - else -> Part.fromText("") - } - } ?: listOf(Part.fromText("")) - - Content.builder() - .role(role) - .parts(parts) - .build() - } - } - - private fun convertFromGeminiResponse(response: GenerateContentResponse): ModelSchema.ChatResponse { - val choices = response.candidates().orElse(emptyList()).mapIndexed { index, candidate -> - val content = candidate.content().orElse(null) - val text = content?.parts()?.orElse(emptyList()) - ?.mapNotNull { it.text().getOrNull() }?.joinToString("\n")?.let { - when (it) { - "" -> null - else -> it - } } - val chatMessageResponse = ModelSchema.ChatMessageResponse( - content = text, - ) - content?.parts()?.orElse(emptyList())?.forEach { part -> - part.inlineData()?.getOrNull()?.apply { - when (mimeType().getOrNull()) { - "image/png", "image/jpeg", "image/jpg", "image/gif" -> { - chatMessageResponse.image_data = this.data().getOrNull() - chatMessageResponse.image_mime_type = this.mimeType().getOrNull() + text != null -> fromText(text) + + else -> fromText("") + } + fun ModelSchema.ContentPart.parts(): List = when { + image_url != null && text != null -> listOfNotNull( + copy(text = null).part(), + copy(image_url = null).part() + ) + else -> listOfNotNull( + this.part() + ) + } + + private fun convertFromGeminiResponse(response: GenerateContentResponse): ModelSchema.ChatResponse { + val choices = response.candidates().orElse(emptyList()).mapIndexed { index, candidate -> + val content = candidate.content().orElse(null) + val text = content?.parts()?.orElse(emptyList()) + ?.mapNotNull { it.text().getOrNull() }?.joinToString("\n")?.let { + when (it) { + "" -> null + else -> it + } + } + + val chatMessageResponse = ModelSchema.ChatMessageResponse( + content = text, + ) + content?.parts()?.orElse(emptyList())?.forEach { part -> + part.inlineData()?.getOrNull()?.apply { + when (mimeType().getOrNull()) { + "image/png", "image/jpeg", "image/jpg", "image/gif" -> { + chatMessageResponse.image_data = this.data().getOrNull() + chatMessageResponse.image_mime_type = this.mimeType().getOrNull() + } + } + } } - } + ModelSchema.ChatChoice( + message = chatMessageResponse, + index = index, + finish_reason = candidate.finishReason().orElse(null)?.toString() + ) } - } - ModelSchema.ChatChoice( - message = chatMessageResponse, - index = index, - finish_reason = candidate.finishReason().orElse(null)?.toString() - ) - } - val usage = response.usageMetadata().orElse(null)?.let { metadata -> - ModelSchema.Usage( - prompt_tokens = metadata.promptTokenCount().orElse(0).toLong(), - completion_tokens = metadata.candidatesTokenCount().orElse(0).toLong(), - total_tokens = metadata.totalTokenCount().orElse(0).toLong() - ) - } + val usage = response.usageMetadata().orElse(null)?.let { metadata -> + ModelSchema.Usage( + prompt_tokens = metadata.promptTokenCount().orElse(0).toLong(), + completion_tokens = metadata.candidatesTokenCount().orElse(0).toLong(), + total_tokens = metadata.totalTokenCount().orElse(0).toLong() + ) + } - return ModelSchema.ChatResponse( - choices = choices, - usage = usage - ) - } + return ModelSchema.ChatResponse( + choices = choices, + usage = usage + ) + } - override fun authorize(request: HttpRequest, apiProvider: APIProvider) { - TODO("Not yet implemented") - } + override fun authorize(request: HttpRequest, apiProvider: APIProvider) { + TODO("Not yet implemented") + } - companion object { - private val log = LoggerFactory.getLogger(GeminiSdkChatClient::class.java) - private val modelsCache = ConcurrentHashMap>() - } + companion object { + private val log = LoggerFactory.getLogger(GeminiSdkChatClient::class.java) + private val modelsCache = ConcurrentHashMap>() + } } \ No newline at end of file diff --git a/jo-penai/src/main/kotlin/com/simiacryptus/cognotik/models/ModelSchema.kt b/jo-penai/src/main/kotlin/com/simiacryptus/cognotik/models/ModelSchema.kt index 080bb9e43..8db05f2f9 100644 --- a/jo-penai/src/main/kotlin/com/simiacryptus/cognotik/models/ModelSchema.kt +++ b/jo-penai/src/main/kotlin/com/simiacryptus/cognotik/models/ModelSchema.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode import com.simiacryptus.cognotik.util.LoggerFactory import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream +import java.nio.file.Path import java.util.* import javax.imageio.ImageIO @@ -468,4 +469,5 @@ interface ModelSchema { val data: List ) -} \ No newline at end of file +} + diff --git a/webapp/package.json b/webapp/package.json index bc0f1ae7c..f48b59c11 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -63,7 +63,7 @@ "concurrently": "^9.1.2", "cross-env": "^7.0.3", "eslint-plugin-react-hooks": "^4.6.0", - "sass": "^1.94.0", + "sass": "^1.94.2", "typescript": "^4.9.5" }, "eslintConfig": { 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 51765297c..cf36a88e4 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 @@ -1,6 +1,7 @@ package com.simiacryptus.cognotik.plan.tools.writing import com.simiacryptus.cognotik.agents.ChatAgent +import com.simiacryptus.cognotik.agents.CodeAgent.Companion.indent import com.simiacryptus.cognotik.agents.ImageAndText import com.simiacryptus.cognotik.agents.ImageProcessingAgent import com.simiacryptus.cognotik.agents.ParsedAgent @@ -12,6 +13,7 @@ import com.simiacryptus.cognotik.plan.tools.truncateForDisplay import com.simiacryptus.cognotik.util.LoggerFactory import com.simiacryptus.cognotik.util.TabbedDisplay import com.simiacryptus.cognotik.util.ValidatedObject +import com.simiacryptus.cognotik.util.toJson import com.simiacryptus.cognotik.webui.chat.transcriptFilter import com.simiacryptus.cognotik.webui.session.SessionTask import com.simiacryptus.cognotik.webui.session.getChildClient @@ -123,7 +125,7 @@ open class NarrativeGenerationTask = emptyList(), val purpose: String = "", val key_events: List = emptyList(), @@ -165,8 +168,9 @@ open class NarrativeGenerationTask - appendLine("#### ${setting.name}") + appendLine("#### ${setting.setting_id}") appendLine() appendLine("**Description:** ${setting.description}") appendLine() @@ -461,36 +486,37 @@ Ensure the structure: resultClass = ActOutline::class.java, prompt = """ You are a master story architect. Expand this act into detailed scenes. -**Overall Story:** ${highLevelOutline.title} -**Premise:** ${highLevelOutline.premise} -**Available Characters:** -${highLevelOutline.characters.joinToString("\n") { "- ${it.name}: ${it.description}" }} -**Available Settings:** -${highLevelOutline.settings.joinToString("\n") { "- ${it.name}: ${it.description}" }} -**Act to Expand:** -- Act ${actSummary.act_number}: ${actSummary.title} -- Purpose: ${actSummary.purpose} -- Key Developments: ${actSummary.key_developments.joinToString("; ")} -- Target Scenes: ${actSummary.estimated_scenes} + +**High-Level Narrative Context:** + ${highLevelOutline.toJson().indent(" ")} + +**Act:** + ${actSummary.toJson().indent(" ")} + **Previous Acts Context:** -${ - detailedActs.joinToString("\n") { act -> - "Act ${act.act_number}: ${act.title} - ${act.scenes?.size ?: 0} scenes" - } + ${ + detailedActs.joinToString("\n") { act -> "Act ${act.act_number}: ${act.title} - ${act.scenes?.size ?: 0} scenes" } + .indent(" ") } + Create approximately ${actSummary.estimated_scenes} scenes for this act. For each scene specify: - Fulfills the act's purpose and key developments -- Uses the established characters and settings consistently -- Builds tension within the act -- Connects smoothly to previous acts (if any) +- Appropriate setting_id from defined settings +- Characters present from defined characters """.trimIndent(), model = api, temperature = 0.7, parsingChatter = parsingChatter ) - try { +try { val expandedAct = sceneExpansionAgent.answer(listOf("Expand act into scenes")).obj - detailedActs.add(expandedAct) + // Add act number to each scene + val actWithNumberedScenes = expandedAct.copy( + scenes = expandedAct.scenes?.map { scene -> + scene.copy(act_number = actSummary.act_number) + } + ) + detailedActs.add(actWithNumberedScenes) log.debug("Expanded Act ${actSummary.act_number} into ${expandedAct.scenes?.size ?: 0} scenes") } catch (e: Exception) { log.error("Failed to expand Act ${actSummary.act_number}", e) @@ -533,7 +559,7 @@ Create approximately ${actSummary.estimated_scenes} scenes for this act. For eac act.scenes?.forEach { scene -> appendLine("#### Scene ${scene.scene_number}: ${scene.title}") appendLine() - appendLine("- **Setting:** ${scene.setting}") + appendLine("- **Setting:** ${scene.setting_id}") appendLine("- **Characters:** ${scene.characters.joinToString(", ")}") appendLine("- **Purpose:** ${scene.purpose}") appendLine("- **Emotional Arc:** ${scene.emotional_arc}") @@ -562,18 +588,8 @@ Create approximately ${actSummary.estimated_scenes} scenes for this act. For eac overviewTask.add("✅ Phase 2 Complete: Outline created (${outline.acts.sumOf { it.scenes?.size ?: 0 }} scenes)\n".renderMarkdown) overviewTask.add("\n### Phase 3: Scene Generation\n*Writing scenes iteratively with context...*\n".renderMarkdown) task.update() - // Generate cover image if enabled - if (genConfig.generate_cover_image) { - generateCoverImage( - task = task, - tabs = tabs, - title = outline.title, - premise = outline.premise, - transcriptWriter = transcript, - orchestrationConfig = orchestrationConfig - ) - } -// Phase 2.5: Generate setting and character images if enabled + + // Phase 2.5: Generate setting and character images if enabled val allScenes = outline.acts.flatMap { it.scenes ?: emptyList() } ?: emptyList() val settingImages = mutableMapOf() val characterImages = mutableMapOf() @@ -591,10 +607,11 @@ Create approximately ${actSummary.estimated_scenes} scenes for this act. For eac tabs = tabs, transcriptWriter = transcript, orchestrationConfig = orchestrationConfig, - settingProfile = setting + settingProfile = setting, + coverImagePath = coverImagePath ) if (settingImagePath != null) { - settingImages[setting.name] = settingImagePath + settingImages[setting.setting_id] = settingImagePath } } @@ -606,7 +623,8 @@ Create approximately ${actSummary.estimated_scenes} scenes for this act. For eac tabs = tabs, characterProfile = character, transcriptWriter = transcript, - orchestrationConfig = orchestrationConfig + orchestrationConfig = orchestrationConfig, + coverImagePath = coverImagePath ) if (characterImagePath != null) { characterImages[character.name] = characterImagePath @@ -623,17 +641,17 @@ Create approximately ${actSummary.estimated_scenes} scenes for this act. For eac var cumulativeWordCount = 0 allScenes.forEachIndexed { index, sceneOutline -> - log.info("Generating scene ${index + 1}/${allScenes.size}: ${sceneOutline?.title}") + log.info("Generating Act ${sceneOutline.act_number}, Scene ${sceneOutline.scene_number}/${allScenes.size}: ${sceneOutline?.title}") - overviewTask.add("- Scene ${sceneOutline?.scene_number}: ${sceneOutline?.title} ".renderMarkdown) + overviewTask.add("- Act ${sceneOutline.act_number}, Scene ${sceneOutline.scene_number}: ${sceneOutline?.title} ".renderMarkdown) task.update() val sceneTask = task.ui.newTask(false) - tabs["Scene ${sceneOutline.scene_number}"] = sceneTask.placeholder + tabs["Act ${sceneOutline.act_number} Scene ${sceneOutline.scene_number}"] = sceneTask.placeholder sceneTask.add( buildString { - appendLine("# Scene ${sceneOutline.scene_number}: ${sceneOutline.title}") + appendLine("# Act ${sceneOutline.act_number}, Scene ${sceneOutline.scene_number}: ${sceneOutline.title}") appendLine() appendLine("**Status:** Writing scene...") appendLine() @@ -669,24 +687,17 @@ Create approximately ${actSummary.estimated_scenes} scenes for this act. For eac "This is the opening scene." } - val sceneAgent = ParsedAgent( +val sceneAgent = ParsedAgent( resultClass = GeneratedScene::class.java, prompt = """ -You are a skilled ${genConfig.writing_style} writer. Write Scene ${sceneOutline.scene_number} of the narrative. + You are a skilled ${genConfig.writing_style} writer. Write Scene ${sceneOutline.scene_number} of the narrative. +This is Act ${sceneOutline.act_number}, Scene ${sceneOutline.scene_number}. -Overall Story: ${outline.title} + Overall Story: ${outline.title} Premise: ${outline.premise} Scene Outline: -- Title: ${sceneOutline.title} -- Setting: ${sceneOutline.setting} -- Characters: ${sceneOutline.characters.joinToString(", ")} -- Purpose: ${sceneOutline.purpose} -- Emotional Arc: ${sceneOutline.emotional_arc} -- Target Word Count: ${sceneOutline.estimated_word_count} - -Key Events to Include: -${sceneOutline.key_events.joinToString("\n") { "- $it" }} + ${sceneOutline.toJson().indent(" ")} $recentContext @@ -719,18 +730,20 @@ Make the writing engaging, immersive, and true to the characters and story. parsingChatter = parsingChatter ) - var generatedScene = sceneAgent.answer(listOf("Write the scene")).obj +var generatedScene = sceneAgent.answer(listOf("Write the scene")).obj + // Ensure act number is preserved + generatedScene = generatedScene.copy(act_number = sceneOutline.act_number) // Optional revision pass - if (genConfig.revision_passes > 0) { +if (genConfig.revision_passes > 0) { repeat(genConfig.revision_passes) { revisionNum -> - log.debug("Revision pass ${revisionNum + 1} for scene ${sceneOutline.scene_number}") + log.debug("Revision pass ${revisionNum + 1} for Act ${sceneOutline.act_number}, Scene ${sceneOutline.scene_number}") val revisionAgent = ChatAgent( prompt = """ -You are an expert editor. Review and improve this scene while maintaining its core events and purpose. + You are an expert editor. Review and improve this scene while maintaining its core events and purpose. -Scene ${sceneOutline.scene_number}: ${sceneOutline.title} +Act ${sceneOutline.act_number}, Scene ${sceneOutline.scene_number}: ${sceneOutline.title} Current Version: ${generatedScene.content} @@ -766,10 +779,12 @@ Provide the revised scene content only. generatedScenes.add(generatedScene) cumulativeWordCount += generatedScene.word_count - val sceneContent = buildString { +val sceneContent = buildString { appendLine("## ${sceneOutline.title}") appendLine() - appendLine("**Setting:** ${sceneOutline.setting}") + appendLine("**Act ${sceneOutline.act_number}, Scene ${sceneOutline.scene_number}**") + appendLine() + appendLine("**Setting:** ${sceneOutline.setting_id}") appendLine() appendLine("**Characters:** ${sceneOutline.characters.joinToString(", ")}") appendLine() @@ -799,22 +814,25 @@ Provide the revised scene content only. task.update() // Add to result - resultBuilder.append("## Scene ${sceneOutline.scene_number}: ${sceneOutline.title}\n\n") + resultBuilder.append("## Act ${sceneOutline.act_number}, Scene ${sceneOutline.scene_number}: ${sceneOutline.title}\n\n") resultBuilder.append(generatedScene.content) resultBuilder.append("\n\n---\n\n") overviewTask.add("✅ (${generatedScene.word_count} words)\n".renderMarkdown) task.update() - // Generate scene image if enabled +// Generate scene image if enabled if (genConfig.generate_scene_images) { generateSceneImage( task = task, tabs = tabs, + actNumber = sceneOutline.act_number, sceneNumber = sceneOutline.scene_number, sceneTitle = sceneOutline.title, sceneContent = generatedScene.content, - setting = sceneOutline.setting, - settingImagePath = settingImages.entries.find { sceneOutline.setting.lowercase().contains(it.key.lowercase()) }?.value, + setting = sceneOutline.setting_id, + settingImagePath = settingImages.entries.find { + sceneOutline.setting_id.lowercase().contains(it.key.lowercase()) + }?.value, characterImagePaths = sceneOutline.characters.mapNotNull { char -> char to (characterImages[char] ?: return@mapNotNull null) }.toMap(), @@ -957,7 +975,7 @@ Provide the revised scene content only. premise: String, transcriptWriter: Writer?, orchestrationConfig: OrchestrationConfig - ) { + ): String? { try { log.info("Generating cover image for: $title") val task = task.ui.newTask(false) @@ -980,11 +998,12 @@ Provide the revised scene content only. val result = imageAgent.answer(listOf(ImageAndText(coverPrompt))) val image = result.image // Save image - val imageFile = task.resolveUserFile("00_cover_image.png")!! + val relativePath = "00_cover_image.png" + val imageFile = task.resolveUserFile(relativePath)!! ImageIO.write(image, "png", imageFile) log.debug("Saved cover image to: ${imageFile.absolutePath}") // Create display link - val link = task.linkTo("00_cover_image.png") + val link = task.linkTo(relativePath) val imageHtml = """

$title

@@ -1007,10 +1026,12 @@ Provide the revised scene content only. transcriptWriter?.flush() task.add("\n**Status:** ✅ Complete\n".renderMarkdown) task.update() + return relativePath } catch (e: Exception) { log.error("Failed to generate cover image", e) transcriptWriter?.appendLine("**Cover Image Generation Failed:** ${e.message}") transcriptWriter?.appendLine() + return null } } @@ -1019,15 +1040,16 @@ Provide the revised scene content only. tabs: TabbedDisplay, settingProfile: SettingProfile, transcriptWriter: Writer?, - orchestrationConfig: OrchestrationConfig + orchestrationConfig: OrchestrationConfig, + coverImagePath: String? ): String? { return try { - log.info("Generating reference image for setting: ${settingProfile.name}") + log.info("Generating reference image for setting: ${settingProfile.setting_id}") val task = task.ui.newTask(false) - tabs["Setting: ${settingProfile.name}"] = task.placeholder + tabs["Setting: ${settingProfile.setting_id}"] = task.placeholder task.add( buildString { - appendLine("# Setting Reference: ${settingProfile.name}") + appendLine("# Setting Reference: ${settingProfile.setting_id}") appendLine() appendLine("**Status:** Generating setting visualization...") appendLine() @@ -1036,21 +1058,42 @@ Provide the revised scene content only. task.update() val imageAgent = ImageProcessingAgent( - prompt = "Create a detailed, atmospheric image of this setting that captures its essence and mood", + prompt = "Create a detailed, atmospheric image of this setting that captures its essence and mood. Use the cover image as visual inspiration for style and atmosphere.", model = orchestrationConfig.imageChatChatter.getChildClient(task), temperature = 0.7, ) val settingPrompt = buildString { - appendLine("${settingProfile.name}") + appendLine("${settingProfile.setting_id}") appendLine("Description: ${settingProfile.description}") appendLine("Atmosphere: ${settingProfile.atmosphere}") }.toString() - val result = imageAgent.answer(listOf(ImageAndText(settingPrompt))) + // Build input with cover image as reference if available + val imageInputs = mutableListOf() + + if (coverImagePath != null) { + try { + val coverImage = ImageIO.read(task.resolveUserFile(coverImagePath)) + if (coverImage != null) { + imageInputs.add( + ImageAndText( + text = "Cover image - use as visual style reference", + image = coverImage + ) + ) + } + } catch (e: Exception) { + log.warn("Failed to load cover image for setting reference: $coverImagePath", e) + } + } + + imageInputs.add(ImageAndText(settingPrompt)) + + val result = imageAgent.answer(imageInputs) val image = result.image // Save image with sanitized filename - val sanitizedName = settingProfile.name.replace(Regex("[^a-zA-Z0-9_-]"), "_").take(50) + val sanitizedName = settingProfile.setting_id.replace(Regex("[^a-zA-Z0-9_-]"), "_").take(50) val relativePath = "setting_${sanitizedName}_ref.png" val imageFile = task.resolveUserFile(relativePath)!! ImageIO.write(image, "png", imageFile) @@ -1060,30 +1103,30 @@ Provide the revised scene content only. val link = task.linkTo(relativePath) val imageHtml = """
-

${settingProfile.name}

+

${settingProfile.setting_id}

Description: ${settingProfile.description}

Atmosphere: ${settingProfile.atmosphere}

Image Prompt: ${result.text}

- ${settingProfile.name} + ${settingProfile.setting_id}
""".trimIndent() task.add(imageHtml.renderMarkdown) task.update() // Write to transcript - transcriptWriter?.appendLine("#### Setting: ${settingProfile.name}") + transcriptWriter?.appendLine("#### Setting: ${settingProfile.setting_id}") transcriptWriter?.appendLine() transcriptWriter?.appendLine("**Prompt:** ${result.text}") transcriptWriter?.appendLine() - transcriptWriter?.appendLine("![Setting: ${settingProfile.name}]($link)".transcriptFilter()) + transcriptWriter?.appendLine("![Setting: ${settingProfile.setting_id}]($link)".transcriptFilter()) transcriptWriter?.appendLine() transcriptWriter?.flush() task.add("\n**Status:** ✅ Complete\n".renderMarkdown) task.update() relativePath } catch (e: Exception) { - log.error("Failed to generate setting reference image for: ${settingProfile.name}", e) + log.error("Failed to generate setting reference image for: ${settingProfile.setting_id}", e) transcriptWriter?.appendLine("**Setting Image Generation Failed:** ${e.message}") transcriptWriter?.appendLine() null @@ -1095,7 +1138,8 @@ Provide the revised scene content only. tabs: TabbedDisplay, characterProfile: CharacterProfile, transcriptWriter: Writer?, - orchestrationConfig: OrchestrationConfig + orchestrationConfig: OrchestrationConfig, + coverImagePath: String? ): String? { return try { log.info("Generating reference image for character: ${characterProfile.name}") @@ -1112,7 +1156,7 @@ Provide the revised scene content only. task.update() val imageAgent = ImageProcessingAgent( - prompt = "Create a detailed character portrait that captures their appearance, personality, and essence", + prompt = "Create a detailed character portrait that captures their appearance, personality, and essence. Use the cover image as visual inspiration for style and atmosphere.", model = orchestrationConfig.imageChatChatter.getChildClient(task), temperature = 0.7, ) @@ -1124,7 +1168,28 @@ Provide the revised scene content only. appendLine("Traits: ${characterProfile.traits.joinToString(", ")}") }.toString() - val result = imageAgent.answer(listOf(ImageAndText(characterPrompt))) + // Build input with cover image as reference if available + val imageInputs = mutableListOf() + + if (coverImagePath != null) { + try { + val coverImage = ImageIO.read(task.resolveUserFile(coverImagePath)) + if (coverImage != null) { + imageInputs.add( + ImageAndText( + text = "Cover image - use as visual style reference", + image = coverImage + ) + ) + } + } catch (e: Exception) { + log.warn("Failed to load cover image for character reference: $coverImagePath", e) + } + } + + imageInputs.add(ImageAndText(characterPrompt)) + + val result = imageAgent.answer(imageInputs) val image = result.image // Save image with sanitized filename val sanitizedName = characterProfile.name.replace(Regex("[^a-zA-Z0-9_-]"), "_").take(50) @@ -1168,9 +1233,10 @@ Provide the revised scene content only. } } - private fun generateSceneImage( +private fun generateSceneImage( task: SessionTask, tabs: TabbedDisplay, + actNumber: Int, sceneNumber: Int, sceneTitle: String, sceneContent: String, @@ -1181,12 +1247,12 @@ Provide the revised scene content only. orchestrationConfig: OrchestrationConfig ) { try { - log.info("Generating image for scene $sceneNumber: $sceneTitle") + log.info("Generating image for Act $actNumber, Scene $sceneNumber: $sceneTitle") val sceneImageTask = task.ui.newTask(false) - tabs["Scene $sceneNumber Image"] = sceneImageTask.placeholder + tabs["Act $actNumber Scene $sceneNumber Image"] = sceneImageTask.placeholder sceneImageTask.add( buildString { - appendLine("# Scene $sceneNumber Image") + appendLine("# Act $actNumber, Scene $sceneNumber Image") appendLine() appendLine("**Status:** Generating scene visualization...") appendLine() @@ -1203,7 +1269,7 @@ Provide the revised scene content only. append("Scene: $sceneTitle. ") append("Setting: $setting. ") // Take first 500 chars of scene content for context - append(sceneContent.take(500)) + append(sceneContent) } // Build input with reference images val imageInputs = mutableListOf() @@ -1252,36 +1318,36 @@ Provide the revised scene content only. val result = imageAgent.answer(imageInputs) val image = result.image // Save image - val relativePath = "scene_${sceneNumber}_image.png" + val relativePath = "act_${actNumber}_scene_${sceneNumber}_image.png" val imageFile = task.resolveUserFile(relativePath)!! ImageIO.write(image, "png", imageFile) log.debug("Saved scene image to: ${imageFile.absolutePath}") // Create display link val link = task.linkTo(relativePath) - val imageHtml = """ +val imageHtml = """
-

Scene $sceneNumber: $sceneTitle

+

Act $actNumber, Scene $sceneNumber: $sceneTitle

Setting: $setting

Image Prompt: ${result.text}

- Scene $sceneNumber + Act $actNumber Scene $sceneNumber
""".trimIndent() sceneImageTask.add(imageHtml.renderMarkdown) task.update() // Write to transcript - transcriptWriter?.appendLine("#### Scene $sceneNumber Image") + transcriptWriter?.appendLine("#### Act $actNumber, Scene $sceneNumber Image") transcriptWriter?.appendLine() transcriptWriter?.appendLine("**Prompt:** ${result.text}") transcriptWriter?.appendLine() - transcriptWriter?.appendLine("![Scene $sceneNumber]($link)".transcriptFilter()) + transcriptWriter?.appendLine("![Act $actNumber Scene $sceneNumber]($link)".transcriptFilter()) transcriptWriter?.appendLine() transcriptWriter?.flush() sceneImageTask.add("\n**Status:** ✅ Complete\n".renderMarkdown) task.update() } catch (e: Exception) { - log.error("Failed to generate scene image for scene $sceneNumber", e) + log.error("Failed to generate scene image for Act $actNumber, Scene $sceneNumber", e) transcriptWriter?.appendLine("**Scene Image Generation Failed:** ${e.message}") transcriptWriter?.appendLine() } diff --git a/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/chat/ChatSocketManager.kt b/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/chat/ChatSocketManager.kt index f11495348..2707ba846 100644 --- a/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/chat/ChatSocketManager.kt +++ b/webui/src/main/kotlin/com/simiacryptus/cognotik/webui/chat/ChatSocketManager.kt @@ -73,7 +73,7 @@ open class ChatSocketManager( } } - val sysMessage: ModelSchema.ChatMessage + open val sysMessage: ModelSchema.ChatMessage get() { return ModelSchema.ChatMessage(ModelSchema.Role.system, systemPrompt.toContentList()) }