This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch quick-fix/ai-question-first in repository https://gitbox.apache.org/repos/asf/camel.git
commit 51d8e4990e3be1551de710435ec141be78d2f601 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 1 16:07:14 2026 +0200 chore: camel-jbang - a question asked in the AI panel goes before the background project summary The automatic project summary starts once the panel is idle; a question that waits behind it on Ollama says so, and Esc stops the summary so the question goes first. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../modules/ROOT/pages/camel-jbang-tui-ai.adoc | 5 +- .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 28 ++++++++++- .../core/commands/tui/ProjectOverviewAssist.java | 57 +++++++++++++++++++++- .../dsl/jbang/core/commands/tui/AiPanelTest.java | 12 +++++ .../commands/tui/ProjectOverviewAssistTest.java | 57 +++++++++++++++++++++- 5 files changed, 155 insertions(+), 4 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc index a538bcb0a3ac..2a1ac2268d8b 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc @@ -299,7 +299,10 @@ into the route sources (`description` and `note` in YAML and XML, `.routeDescrip `/write` mode. From then on they are part of the routes and no longer marked. With *AI Overview* set to *auto* in *F2 -> Settings*, the panel explains the project by itself when -it opens and the summary is missing or out of date, once per project state. External AI agents get the +it opens and the summary is missing or out of date, once per project state. It starts once the panel has +been idle for a few seconds, so a question asked right away is answered first. A local model answers one +request at a time: a question asked while the summary is being written waits for it, and the panel says +so; *Esc* then stops the summary so the question goes first (`/overview` writes it later). External AI agents get the same through the `camel_project_overview` and `camel_save_project_summary` tools; from the TUI's MCP server the summary they save goes through the confirm dialog too. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java index 05142a375076..16b96c78ba68 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -162,6 +162,7 @@ class AiPanel { // Input state private final StringBuilder inputBuffer = new StringBuilder(); + private volatile long lastInputMs; private int cursorPos; private TuiPromptHistory promptHistory; @@ -531,14 +532,24 @@ class AiPanel { // the project is kept off the UI thread LlmClient c = client; Path dir = projectDirectory(); + // it waits until the panel is idle, so a question asked right away is answered first Thread t = new Thread( - () -> projectOverview.autoExplain(dir, c, mcpFacade, this::addOverviewEntry), + () -> projectOverview.autoExplain(dir, c, mcpFacade, this::addOverviewEntry, + () -> idleFor(OVERVIEW_IDLE_MS, System.currentTimeMillis())), "tui-ai-overview-check"); t.setDaemon(true); t.start(); } } + /** How long the panel is left alone (nothing typed, no question running) before the summary starts. */ + static final long OVERVIEW_IDLE_MS = 3000; + + /** Whether the panel has been idle for a while: no question or command running, nothing typed. */ + boolean idleFor(long ms, long now) { + return !thinking.get() && activeCliCommand == null && inputBuffer.isEmpty() && now - lastInputMs >= ms; + } + /** The project the AI overview is about: the selected integration's sources, else the folder the TUI runs in. */ private Path projectDirectory() { Path dir = mcpFacade != null ? mcpFacade.getSelectedSourceDirectory() : null; @@ -811,6 +822,7 @@ class AiPanel { } boolean handleKeyEvent(KeyEvent ke) { + lastInputMs = System.currentTimeMillis(); if (permissionPopup.isVisible()) { if (ke.isCtrlC()) { interruptBusyOperation(); @@ -1440,6 +1452,13 @@ class AiPanel { } private void interruptBusyOperation() { + if (thinking.get() && projectOverview.stop()) { + // the question waits behind the project summary: the first Esc stops the summary, so it goes first + conversation.add(new ConversationEntry( + AiRole.SYSTEM, + "(project summary stopped, your question goes first; /overview writes it later)")); + return; + } // A background CLI command and an LLM request can be in flight at the same time, so cancel each one // independently. Cancelling the CLI must not touch the LLM's thinking state (that belongs to the agent // thread, which clears it in its own finally block) and vice versa. @@ -1504,6 +1523,13 @@ class AiPanel { return; } conversation.add(new ConversationEntry(AiRole.USER, question)); + if (projectOverview.isRunning() && client.apiType() == LlmClient.ApiType.ollama) { + // Ollama answers one request at a time: say why the answer takes long, and how to go first + conversation.add(new ConversationEntry( + AiRole.SYSTEM, + "The project summary (/overview) is being written, and Ollama answers one request at a time:" + + " this question waits for it. Esc stops the summary.")); + } questionCounter++; currentQuestion = question; noteQuestionStarted(question); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java index bc73042e93f2..6a9f5b2616cd 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java @@ -27,6 +27,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.catalog.DefaultCamelCatalog; @@ -62,6 +63,8 @@ final class ProjectOverviewAssist { private static volatile CamelCatalog catalog; private final AtomicBoolean running = new AtomicBoolean(); + private volatile Thread worker; + private volatile boolean stopRequested; /** The projects auto mode already explained in this session, by directory and fingerprint. */ private final Set<String> autoDone = ConcurrentHashMap.newKeySet(); @@ -143,6 +146,15 @@ final class ProjectOverviewAssist { * @return true when it started */ boolean autoExplain(Path dir, LlmClient client, McpFacade facade, Sink sink) { + return autoExplain(dir, client, facade, sink, () -> true); + } + + /** + * As {@link #autoExplain(Path, LlmClient, McpFacade, Sink)}, but starts only once the panel is idle: a local model + * answers one request at a time, so a question asked right after the panel opens would otherwise wait for the whole + * summary. Blocks the calling (background) thread until then. + */ + boolean autoExplain(Path dir, LlmClient client, McpFacade facade, Sink sink, BooleanSupplier idle) { if (!MODE_AUTO.equals(mode()) || dir == null || client == null || running.get()) { return false; } @@ -154,6 +166,9 @@ final class ProjectOverviewAssist { if (summary != null && !summary.ai().isEmpty() && overview.fingerprint().equals(summary.fingerprint())) { return false; } + if (!awaitIdle(idle, IDLE_WAIT_MS) || running.get()) { + return false; + } sink.add(AiRole.SYSTEM, "The project summary is " + (summary == null ? "missing" : "out of date") + ": explaining " + overview.flows().size() + " routes with " + client.model() + " in the background (AI Overview: auto)."); @@ -161,11 +176,47 @@ final class ProjectOverviewAssist { return true; } + /** How long the summary waits for the panel to become idle before it gives up for this session. */ + static final long IDLE_WAIT_MS = 10 * 60_000; + + /** Waits until {@code idle} holds, checking a few times a second; false when it never did in time. */ + static boolean awaitIdle(BooleanSupplier idle, long maxWaitMs) { + long until = System.currentTimeMillis() + maxWaitMs; + while (!idle.getAsBoolean()) { + if (System.currentTimeMillis() >= until) { + return false; + } + try { + Thread.sleep(250); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return true; + } + + /** + * Stops the summary being written, so a question waiting behind it on a local model goes first. + * + * @return true when one was running + */ + boolean stop() { + Thread w = worker; + if (!running.get() || w == null) { + return false; + } + stopRequested = true; + w.interrupt(); + return true; + } + private void explain( Path dir, ProjectOverview.Overview overview, IntegrationSummary.Summary previous, LlmClient client, Sink sink) { if (!running.compareAndSet(false, true)) { return; } + stopRequested = false; Thread t = new Thread(() -> { try { String answer = ask(client, IntegrationSummary.systemPrompt(), @@ -187,12 +238,16 @@ final class ProjectOverviewAssist { overview.fingerprint(), client.model(), null, merged.overview(), merged.capabilities(), merged.descriptions()))); } catch (Exception e) { - sink.add(AiRole.ERROR, "Explaining the project failed: " + e.getMessage()); + if (!stopRequested) { + sink.add(AiRole.ERROR, "Explaining the project failed: " + e.getMessage()); + } } finally { + worker = null; running.set(false); } }, "tui-ai-overview"); t.setDaemon(true); + worker = t; t.start(); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java index 4074e7a8b27d..8df01c361505 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java @@ -68,6 +68,18 @@ class AiPanelTest { assertEquals("2.0s · 1 tool call · 900 tokens", hosted.byline()); } + @Test + void thePanelIsIdleOnlyWhenNothingIsTypedOrAsked() { + AiPanel panel = new AiPanel(); + long now = System.currentTimeMillis(); + assertTrue(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, now), "nothing typed yet"); + + type(panel, "why"); + assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, System.currentTimeMillis()), "typing a question"); + // still typed, even long after the last key + assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, System.currentTimeMillis() + 60_000)); + } + @Test void normalTextStillGoesToLlm() throws Exception { AiPanel panel = new AiPanel(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssistTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssistTest.java index fecd2a523e5b..6b53aa29d340 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssistTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssistTest.java @@ -21,7 +21,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.apache.camel.dsl.jbang.core.commands.LlmClient; @@ -178,6 +180,59 @@ class ProjectOverviewAssistTest { assertFalse(assist.autoExplain(project, client, null, sink)); } + @Test + void theSummaryWaitsUntilThePanelIsIdle() throws Exception { + TuiSettings settings = TuiSettings.load(); + settings.setAiOverview("auto"); + settings.save(); + AnsweringClient client = new AnsweringClient(ANSWER); + ProjectOverviewAssist assist = new ProjectOverviewAssist(); + AtomicBoolean idle = new AtomicBoolean(); + + // the user asks a question right after opening the panel: the summary must not go first + Thread check = new Thread(() -> assist.autoExplain(project, client, null, sink, idle::get)); + check.start(); + check.join(1000); + assertTrue(check.isAlive(), "waits while the panel is busy"); + assertEquals(0, client.calls.get()); + assertTrue(entries.isEmpty()); + + idle.set(true); + await().atMost(10, TimeUnit.SECONDS).until(() -> client.calls.get() == 1 && !assist.isRunning()); + assertTrue(entries.get(0).contains("(AI Overview: auto)"), entries.get(0)); + } + + @Test + void aStoppedSummaryLeavesNoError() { + TuiSettings settings = TuiSettings.load(); + settings.setAiOverview("auto"); + settings.save(); + CountDownLatch asked = new CountDownLatch(1); + LlmClient slow = new AnsweringClient(ANSWER) { + @Override + public ChatResponse chatWithTools(String systemPrompt, List<Message> messages, List<ToolDef> tools) { + asked.countDown(); + try { + // a local model writing a long summary + new CountDownLatch(1).await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted", e); + } + return null; + } + }; + ProjectOverviewAssist assist = new ProjectOverviewAssist(); + assertFalse(assist.stop(), "nothing to stop"); + + assertTrue(assist.autoExplain(project, slow, null, sink)); + await().atMost(10, TimeUnit.SECONDS).until(() -> asked.getCount() == 0); + assertTrue(assist.stop()); + await().atMost(10, TimeUnit.SECONDS).until(() -> !assist.isRunning()); + assertEquals(1, entries.size(), "only the start was said: " + entries); + assertFalse(Files.exists(project.resolve(IntegrationSummary.FILE_NAME))); + } + @Test void applyNeedsTheSelectedIntegration() { ProjectOverviewAssist assist = new ProjectOverviewAssist(); @@ -204,7 +259,7 @@ class ProjectOverviewAssistTest { } } - private static final class AnsweringClient extends LlmClient { + private static class AnsweringClient extends LlmClient { private final String answer; private final AtomicInteger calls = new AtomicInteger();
