diff --git a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt index cef08bb..a0ccb3e 100644 --- a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt +++ b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt @@ -3,7 +3,7 @@ package org.openprojectx.ai.plugin.llm data class LlmSettings( val provider: String, val model: String, - val timeoutSeconds: Long = 60, + val timeoutSeconds: Long = 180, val apiKey: String? = null, val endpoint: String? = null, val template: TemplateRequestConfig? = null, diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt index 89cf41e..d11f8be 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt @@ -17,13 +17,17 @@ import javax.swing.JButton import javax.swing.JCheckBox import javax.swing.JComboBox import javax.swing.JComponent +import javax.swing.DefaultListModel import javax.swing.JLabel +import javax.swing.JList import javax.swing.JPanel import javax.swing.JPasswordField import javax.swing.JScrollPane +import javax.swing.JSplitPane import javax.swing.JTabbedPane import javax.swing.JTextArea import javax.swing.JTextField +import javax.swing.ListSelectionModel import javax.swing.SwingConstants import org.yaml.snakeyaml.DumperOptions import org.yaml.snakeyaml.Yaml @@ -305,83 +309,148 @@ class AiTestSettingsConfigurable( }).apply { border = BorderFactory.createEmptyBorder() } private fun generationPromptManagerSection(): JComponent { - val addButton = JButton("Add Test Prompt").apply { + return buildInteractivePromptManagerSection( + title = "Test Prompt Manager", + typeLabel = "Test", + profilesYamlField = generationPromptProfilesYamlField, + defaultField = generationPromptProfileDefaultField + ) + } + + private fun commitPromptManagerSection(): JComponent { + return buildInteractivePromptManagerSection( + title = "Commit Prompt Manager", + typeLabel = "Commit", + profilesYamlField = commitPromptProfilesYamlField, + defaultField = commitPromptProfileDefaultField + ) + } + + private fun branchDiffPromptManagerSection(): JComponent { + return buildInteractivePromptManagerSection( + title = "Branch Diff Prompt Manager", + typeLabel = "Branch Diff", + profilesYamlField = branchDiffPromptProfilesYamlField, + defaultField = branchDiffPromptProfileDefaultField + ) + } + + private fun buildInteractivePromptManagerSection( + title: String, + typeLabel: String, + profilesYamlField: JTextArea, + defaultField: JTextField + ): JComponent { + val listModel = DefaultListModel() + val nameList = JList(listModel).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + } + val editorArea = textArea(8) + val editorScroll = JScrollPane(editorArea) + val editorPanel = JPanel(BorderLayout(0, 8)).apply { + add(editorScroll, BorderLayout.CENTER) + isVisible = false + } + + fun refreshList(selectedName: String? = null) { + val map = parseYamlMap(profilesYamlField.text) + listModel.clear() + map.keys.forEach { listModel.addElement(it) } + when { + selectedName != null && map.containsKey(selectedName) -> nameList.setSelectedValue(selectedName, true) + listModel.size() > 0 -> nameList.selectedIndex = 0 + else -> { + nameList.clearSelection() + editorPanel.isVisible = false + } + } + } + + nameList.addListSelectionListener { + val selected = nameList.selectedValue + if (selected == null) { + editorPanel.isVisible = false + editorArea.text = "" + } else { + editorPanel.isVisible = true + editorArea.text = parseYamlMap(profilesYamlField.text)[selected].orEmpty() + editorArea.caretPosition = 0 + } + } + + val addButton = JButton("Add").apply { addActionListener { - addPromptProfile( - typeLabel = "Test", - nameField = generationPromptNewNameField, - valueField = generationPromptNewValueField, - profilesYamlField = generationPromptProfilesYamlField, - defaultField = generationPromptProfileDefaultField - ) + val name = Messages.showInputDialog( + project, + "Enter $typeLabel prompt name", + "Add Prompt", + null + )?.trim().orEmpty() + if (name.isBlank()) return@addActionListener + val map = parseYamlMap(profilesYamlField.text).toMutableMap() + if (map.containsKey(name)) { + Messages.showErrorDialog(project, "$typeLabel prompt '$name' already exists.", "AI Test Generator") + return@addActionListener + } + map[name] = "" + profilesYamlField.text = dumpYamlMap(map) + if (defaultField.text.isBlank()) { + defaultField.text = name + } + refreshList(name) } } - return formSection("Test Prompt Manager", listOf( - "New prompt name" to generationPromptNewNameField, - "New prompt value" to JScrollPane(generationPromptNewValueField), - "Action" to JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(addButton) } - )) - } - private fun commitPromptManagerSection(): JComponent { - val addButton = JButton("Add Commit Prompt").apply { + val deleteButton = JButton("Delete").apply { addActionListener { - addPromptProfile( - typeLabel = "Commit", - nameField = commitPromptNewNameField, - valueField = commitPromptNewValueField, - profilesYamlField = commitPromptProfilesYamlField, - defaultField = commitPromptProfileDefaultField - ) + val selected = nameList.selectedValue ?: return@addActionListener + val map = parseYamlMap(profilesYamlField.text).toMutableMap() + map.remove(selected) + if (map.isEmpty()) { + map[PromptProfileSet.DEFAULT_NAME] = "" + } + profilesYamlField.text = dumpYamlMap(map) + if (defaultField.text == selected) { + defaultField.text = map.keys.firstOrNull().orEmpty() + } + refreshList() } } - return formSection("Commit Prompt Manager", listOf( - "New prompt name" to commitPromptNewNameField, - "New prompt value" to JScrollPane(commitPromptNewValueField), - "Action" to JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(addButton) } - )) - } - private fun branchDiffPromptManagerSection(): JComponent { - val addButton = JButton("Add Branch Diff Prompt").apply { + val saveButton = JButton("Save").apply { addActionListener { - addPromptProfile( - typeLabel = "Branch Diff", - nameField = branchDiffPromptNewNameField, - valueField = branchDiffPromptNewValueField, - profilesYamlField = branchDiffPromptProfilesYamlField, - defaultField = branchDiffPromptProfileDefaultField - ) + val selected = nameList.selectedValue ?: return@addActionListener + val map = parseYamlMap(profilesYamlField.text).toMutableMap() + map[selected] = editorArea.text + profilesYamlField.text = dumpYamlMap(map) + refreshList(selected) } } - return formSection("Branch Diff Prompt Manager", listOf( - "New prompt name" to branchDiffPromptNewNameField, - "New prompt value" to JScrollPane(branchDiffPromptNewValueField), - "Action" to JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(addButton) } - )) - } - private fun addPromptProfile( - typeLabel: String, - nameField: JTextField, - valueField: JTextArea, - profilesYamlField: JTextArea, - defaultField: JTextField - ) { - val name = nameField.text.trim() - val value = valueField.text.trim() - if (name.isBlank() || value.isBlank()) { - Messages.showErrorDialog(project, "$typeLabel prompt name and value are required.", "AI Test Generator") - return + val buttonRow = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + add(addButton) + add(deleteButton) + add(saveButton) + } + + val split = JSplitPane(JSplitPane.VERTICAL_SPLIT).apply { + topComponent = JScrollPane(nameList) + bottomComponent = editorPanel + resizeWeight = 0.4 + border = BorderFactory.createEmptyBorder() } - val map = parseYamlMap(profilesYamlField.text).toMutableMap() - map[name] = value - profilesYamlField.text = dumpYamlMap(map) - if (defaultField.text.isBlank()) { - defaultField.text = name + + refreshList() + + return JPanel(BorderLayout(0, 8)).apply { + border = BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder(title), + BorderFactory.createEmptyBorder(8, 8, 8, 8) + ) + add(buttonRow, BorderLayout.NORTH) + add(split, BorderLayout.CENTER) + maximumSize = Dimension(Int.MAX_VALUE, 420) } - nameField.text = "" - valueField.text = "" } private fun formSection(title: String, rows: List>): JComponent { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt index 87b921c..03cfd6c 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt @@ -6,7 +6,7 @@ data class AiTestSettingsModel( val llmProvider: String = "openai-compatible", val llmModel: String = "", val llmEndpoint: String = "", - val llmTimeoutSeconds: String = "60", + val llmTimeoutSeconds: String = "180", val llmApiKey: String = "", val llmApiKeyEnv: String = "", val llmTemplateEnabled: Boolean = false, diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index b29db5a..1f14830 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -2,20 +2,42 @@ package org.openprojectx.ai.plugin import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.components.JBScrollPane import com.intellij.ui.content.ContentFactory import java.awt.BorderLayout import java.awt.Color +import java.awt.FlowLayout import java.awt.Font import javax.swing.BorderFactory +import javax.swing.BoxLayout +import javax.swing.JButton import javax.swing.JPanel +import javax.swing.JSplitPane +import javax.swing.JTabbedPane import javax.swing.JTextArea import javax.swing.UIManager +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.Yaml class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { + data class PromptLeaf( + val name: String, + val content: String + ) { + override fun toString(): String = name + } + + enum class PromptCategory { + GENERATION, + COMMIT, + BRANCH_DIFF, + PULL_REQUEST + } + override fun shouldBeAvailable(project: Project): Boolean = true override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { @@ -39,6 +61,11 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { border = BorderFactory.createEmptyBorder(10, 10, 10, 10) } + val promptCategoryTabs = JTabbedPane().apply { + background = bgColor + foreground = fgColor + } + fun styledScrollPane(component: java.awt.Component): JBScrollPane = JBScrollPane(component).apply { viewport.background = bgColor @@ -46,15 +73,282 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { border = BorderFactory.createLineBorder(borderColor) } - val panel = JPanel(BorderLayout()).apply { + fun parseYamlMap(text: String): Map { + val parsed = Yaml().load(text) as? Map<*, *> ?: emptyMap() + val result = linkedMapOf() + parsed.forEach { (k, v) -> + val key = k?.toString()?.trim().orEmpty() + if (key.isNotBlank()) { + result[key] = v?.toString().orEmpty() + } + } + return result + } + + fun dumpYamlMap(value: Map): String { + val options = DumperOptions().apply { + defaultFlowStyle = DumperOptions.FlowStyle.BLOCK + indent = 2 + isPrettyFlow = true + } + return Yaml(options).dump(value).trimEnd() + } + + fun updateYamlProfile(yaml: String, name: String, value: String): String { + val map = parseYamlMap(yaml).toMutableMap() + map[name] = value + return dumpYamlMap(map) + } + + fun removeYamlProfile(yaml: String, name: String): String { + val map = parseYamlMap(yaml).toMutableMap() + map.remove(name) + if (map.isEmpty()) { + map[PromptProfileSet.DEFAULT_NAME] = "" + } + return dumpYamlMap(map) + } + + fun renameYamlProfile(yaml: String, oldName: String, newName: String): String { + val map = parseYamlMap(yaml) + if (!map.containsKey(oldName) || map.containsKey(newName)) return yaml + val renamed = linkedMapOf() + map.forEach { (key, value) -> + if (key == oldName) { + renamed[newName] = value + } else { + renamed[key] = value + } + } + return dumpYamlMap(renamed) + } + + fun savePromptValue(category: PromptCategory, name: String, value: String) { + val model = LlmSettingsLoader.loadSettingsModel(project) + val updated = when (category) { + PromptCategory.GENERATION -> model.copy( + generationPromptProfilesYaml = updateYamlProfile(model.generationPromptProfilesYaml, name, value) + ) + PromptCategory.COMMIT -> model.copy( + commitPromptProfilesYaml = updateYamlProfile(model.commitPromptProfilesYaml, name, value) + ) + PromptCategory.BRANCH_DIFF -> model.copy( + branchDiffPromptProfilesYaml = updateYamlProfile(model.branchDiffPromptProfilesYaml, name, value) + ) + PromptCategory.PULL_REQUEST -> model.copy(pullRequestPrompt = value) + } + LlmSettingsLoader.saveSettingsModel(project, updated) + } + + fun addPrompt(category: PromptCategory, name: String) { + val model = LlmSettingsLoader.loadSettingsModel(project) + val updated = when (category) { + PromptCategory.GENERATION -> model.copy( + generationPromptProfilesYaml = updateYamlProfile(model.generationPromptProfilesYaml, name, "") + ) + PromptCategory.COMMIT -> model.copy( + commitPromptProfilesYaml = updateYamlProfile(model.commitPromptProfilesYaml, name, "") + ) + PromptCategory.BRANCH_DIFF -> model.copy( + branchDiffPromptProfilesYaml = updateYamlProfile(model.branchDiffPromptProfilesYaml, name, "") + ) + PromptCategory.PULL_REQUEST -> return + } + LlmSettingsLoader.saveSettingsModel(project, updated) + } + + fun deletePrompt(category: PromptCategory, name: String) { + val model = LlmSettingsLoader.loadSettingsModel(project) + val updated = when (category) { + PromptCategory.GENERATION -> model.copy( + generationPromptProfilesYaml = removeYamlProfile(model.generationPromptProfilesYaml, name) + ) + PromptCategory.COMMIT -> model.copy( + commitPromptProfilesYaml = removeYamlProfile(model.commitPromptProfilesYaml, name) + ) + PromptCategory.BRANCH_DIFF -> model.copy( + branchDiffPromptProfilesYaml = removeYamlProfile(model.branchDiffPromptProfilesYaml, name) + ) + PromptCategory.PULL_REQUEST -> return + } + LlmSettingsLoader.saveSettingsModel(project, updated) + } + + fun buildPromptLeafMap(): LinkedHashMap>> { + val config = runCatching { LlmSettingsLoader.loadConfig(project) }.getOrNull() + ?: return linkedMapOf("Error" to (PromptCategory.PULL_REQUEST to listOf(PromptLeaf("Load failed", "Unable to load .ai-test.yaml")))) + val prompts = config.prompts + return linkedMapOf( + "Test Generation" to (PromptCategory.GENERATION to prompts.profiles.generation.items.entries.map { PromptLeaf(it.key, it.value) }), + "Commit Message" to (PromptCategory.COMMIT to prompts.profiles.commitMessage.items.entries.map { PromptLeaf(it.key, it.value) }), + "Branch Diff" to (PromptCategory.BRANCH_DIFF to prompts.profiles.branchDiffSummary.items.entries.map { PromptLeaf(it.key, it.value) }), + "Pull Request" to (PromptCategory.PULL_REQUEST to listOf(PromptLeaf("default", prompts.pullRequest))) + ) + } + + fun refreshPromptTabs() { + promptCategoryTabs.removeAll() + val promptMap = buildPromptLeafMap() + promptMap.forEach { (categoryName, categoryAndItems) -> + val (category, items) = categoryAndItems + val promptArea = JTextArea().apply { + isEditable = true + lineWrap = true + wrapStyleWord = true + font = commonFont + background = bgColor + foreground = fgColor + caretColor = fgColor + border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + text = "" + } + val buttonListPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + background = bgColor + border = BorderFactory.createEmptyBorder(8, 8, 8, 8) + } + var selectedPromptName: String? = null + + val editorPanel = JPanel(BorderLayout()).apply { + add(styledScrollPane(promptArea), BorderLayout.CENTER) + isVisible = false + } + + fun selectPrompt(leaf: PromptLeaf) { + selectedPromptName = leaf.name + promptArea.text = leaf.content + promptArea.caretPosition = 0 + editorPanel.isVisible = true + } + + items.forEach { leaf -> + val button = JButton(leaf.name).apply { + alignmentX = 0.0f + horizontalAlignment = JButton.LEFT + addActionListener { selectPrompt(leaf) } + } + buttonListPanel.add(button) + } + + val addButton = JButton("Add").apply { + addActionListener { + if (category == PromptCategory.PULL_REQUEST) return@addActionListener + val name = Messages.showInputDialog( + project, + "Enter prompt name for $categoryName", + "Add Prompt", + null + )?.trim().orEmpty() + if (name.isBlank()) return@addActionListener + addPrompt(category, name) + refreshPromptTabs() + } + } + val deleteButton = JButton("Delete").apply { + addActionListener { + if (category == PromptCategory.PULL_REQUEST) return@addActionListener + val selected = selectedPromptName ?: return@addActionListener + deletePrompt(category, selected) + refreshPromptTabs() + } + } + val renameButton = JButton("Rename").apply { + addActionListener { + if (category == PromptCategory.PULL_REQUEST) return@addActionListener + val selected = selectedPromptName ?: return@addActionListener + val newName = Messages.showInputDialog( + project, + "Rename prompt '$selected' to:", + "Rename Prompt", + null + )?.trim().orEmpty() + if (newName.isBlank() || newName == selected) return@addActionListener + + val model = LlmSettingsLoader.loadSettingsModel(project) + val renamedYaml = when (category) { + PromptCategory.GENERATION -> renameYamlProfile(model.generationPromptProfilesYaml, selected, newName) + PromptCategory.COMMIT -> renameYamlProfile(model.commitPromptProfilesYaml, selected, newName) + PromptCategory.BRANCH_DIFF -> renameYamlProfile(model.branchDiffPromptProfilesYaml, selected, newName) + PromptCategory.PULL_REQUEST -> return@addActionListener + } + val updated = when (category) { + PromptCategory.GENERATION -> model.copy( + generationPromptProfilesYaml = renamedYaml, + generationPromptProfileDefault = if (model.generationPromptProfileDefault == selected) newName else model.generationPromptProfileDefault + ) + PromptCategory.COMMIT -> model.copy( + commitPromptProfilesYaml = renamedYaml, + commitPromptProfileDefault = if (model.commitPromptProfileDefault == selected) newName else model.commitPromptProfileDefault + ) + PromptCategory.BRANCH_DIFF -> model.copy( + branchDiffPromptProfilesYaml = renamedYaml, + branchDiffPromptProfileDefault = if (model.branchDiffPromptProfileDefault == selected) newName else model.branchDiffPromptProfileDefault + ) + PromptCategory.PULL_REQUEST -> model + } + LlmSettingsLoader.saveSettingsModel(project, updated) + refreshPromptTabs() + } + } + val saveButton = JButton("Save").apply { + addActionListener { + val selected = selectedPromptName ?: return@addActionListener + savePromptValue(category, selected, promptArea.text) + refreshPromptTabs() + } + } + + val buttonRow = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + background = bgColor + add(addButton) + add(deleteButton) + add(renameButton) + add(saveButton) + } + + val split = JSplitPane(JSplitPane.VERTICAL_SPLIT).apply { + topComponent = styledScrollPane(buttonListPanel) + bottomComponent = editorPanel + resizeWeight = 0.45 + border = BorderFactory.createEmptyBorder() + background = bgColor + } + + val categoryPanel = JPanel(BorderLayout(0, 8)).apply { + background = bgColor + add(buttonRow, BorderLayout.NORTH) + add(split, BorderLayout.CENTER) + } + + promptCategoryTabs.addTab(categoryName, categoryPanel) + } + } + + refreshPromptTabs() + + val resultPanel = JPanel(BorderLayout()).apply { add(styledScrollPane(resultArea), BorderLayout.CENTER) background = bgColor foreground = fgColor } + val promptPanel = JPanel(BorderLayout()).apply { + add(promptCategoryTabs, BorderLayout.CENTER) + background = bgColor + foreground = fgColor + } + + val rootTabs = JTabbedPane().apply { + addTab("Latest Result", resultPanel) + addTab("Prompt Manager", promptPanel) + background = bgColor + foreground = fgColor + } + fun render(snapshot: ContextBoxStateService.Snapshot) { resultArea.text = snapshot.latestResult resultArea.caretPosition = 0 + refreshPromptTabs() } render(stateService.snapshot()) @@ -66,7 +360,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } ) - val content = ContentFactory.getInstance().createContent(panel, "", false) + val content = ContentFactory.getInstance().createContent(rootTabs, "", false) toolWindow.contentManager.addContent(content) } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt index 260eeb9..dab1bff 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt @@ -31,7 +31,7 @@ object LlmProviderFactory { install(HttpTimeout) { connectTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) socketTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) -// requestTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) + requestTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) } install(Logging) { logger = Logger.DEFAULT // prints to stdout diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt index 90c67c6..d1d3261 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt @@ -49,7 +49,7 @@ object LlmSettingsLoader { llmProvider = llm.string("provider").ifBlank { "openai-compatible" }, llmModel = llm.string("model"), llmEndpoint = llm.string("endpoint"), - llmTimeoutSeconds = llm.string("timeoutSeconds").ifBlank { "60" }, + llmTimeoutSeconds = llm.string("timeoutSeconds").ifBlank { "180" }, llmApiKey = llm.string("apiKey"), llmApiKeyEnv = llm.string("apiKeyEnv"), llmTemplateEnabled = template != null, @@ -183,9 +183,9 @@ object LlmSettingsLoader { val timeoutSeconds = when (val v = llm["timeoutSeconds"]) { is Number -> v.toLong() is String -> v.trim().toLongOrNull() - null -> 60L + null -> 180L else -> error("Invalid YAML: llm.timeoutSeconds must be a number or string number") - } ?: 60L + } ?: 180L val endpoint = (llm["endpoint"] as? String)?.trim()?.takeIf { it.isNotEmpty() } @@ -302,6 +302,7 @@ object LlmSettingsLoader { endpoint: https://api.openai.com/v1/chat/completions model: gpt-4.1 apiKeyEnv: OPENAI_API_KEY + timeoutSeconds: 180 generation: defaults: