This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-ollama-tab in repository https://gitbox.apache.org/repos/asf/camel.git
commit e5a0c17cb59979f5cd189b0fdf54baa9b0664de4 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Sep 17 12:57:16 2026 +0200 CAMEL-24794: camel-jbang - TUI Ollama tab groups the request log per question A question asked in the AI panel costs one request per tool-call step, so a single question filled ten lines of the log while the model column repeated the one local model on every line. The log now shows one line per question: the question text (first line, cut to fit) where the model was, the number of requests it took, and the figures for the question as a whole: the largest prompt, generated tokens, cache hit across the steps, the highest context fill, prefill and decode rates across the steps, the time to the first token of the first step and the wall time from the first request to the last. Enter or Right unfolds the steps with their own figures and the model, Left folds them. Route calls stay one line each. The AI panel passes the question number and text to the monitor; the JSON of tui_get_ollama carries them per request and adds a questions array with the aggregates. F1 help and the user manual describe the grouped view. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 8 +- .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 7 +- .../dsl/jbang/core/commands/tui/OllamaMonitor.java | 197 ++++++++++++++++++++- .../dsl/jbang/core/commands/tui/OllamaTab.java | 183 +++++++++++++++---- .../src/main/resources/tui/help/ollama.md | 42 +++-- .../jbang/core/commands/tui/OllamaMonitorTest.java | 54 ++++++ .../core/commands/tui/OllamaTabRenderTest.java | 42 ++++- 7 files changed, 474 insertions(+), 59 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index 369e9e2de5ad..2d3f8a0373a6 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -432,9 +432,11 @@ the TUI checks every ten seconds, so it appears shortly after `ollama serve` sta of how much of the window each AI panel prompt filled, with the session peak and the compactions seen. * *Host* -- GPU utilization and memory (Apple silicon through `ioreg`, NVIDIA through `nvidia-smi`), and CPU and memory of the Ollama server and its model runner. -* *Requests* -- one line per request with input, output and cached tokens, the share of the context - window the prompt filled, prefill and decode tokens per second, time to first token, total time and - the stop reason. +* *Requests* -- one line per question asked in the AI panel (a question with tool calls costs one + request per step; *Enter* unfolds the steps) with the question text, prompt and generated tokens, + cache hit, the share of the context window reached, prefill and decode tokens per second, time to + first token, the time you waited and the stop reason. Calls made by Camel routes are listed as + their own lines. Two kinds of requests appear in the log. Questions asked in the AI panel with Ollama as the provider come with the timings Ollama reports for each request (prompt evaluation, generation, model load, 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 1e2608825dfa..bb046a7e18b0 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 @@ -298,6 +298,8 @@ class AiPanel { private final List<AiUsageEntry> usageHistory = new CopyOnWriteArrayList<>(); /** Sequence number of the current question; tags the usage entries recorded while answering it. */ private volatile int questionCounter; + /** The question the current turn answers, for grouping its requests on the Ollama tab. */ + private volatile String currentQuestion; /** Route usage (GenAI spans) recorded before this instant is left out after a {@code /usage reset}. */ private volatile Instant usageResetAt = Instant.EPOCH; private AtomicReference<List<SpanEntry>> otelSpans = new AtomicReference<>(List.of()); @@ -1170,6 +1172,7 @@ class AiPanel { // the waiting camel_write_file call returns with the question and the model answers in this turn conversation.add(new ConversationEntry(AiRole.USER, input)); questionCounter++; + currentQuestion = input; log(LogLevel.QUESTION, "Question about the edit", input); return; } @@ -1354,6 +1357,7 @@ class AiPanel { } conversation.add(new ConversationEntry(AiRole.USER, question)); questionCounter++; + currentQuestion = question; log(LogLevel.QUESTION, "Question", question); thinkingVerb = THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size())); thinkingStartTime = System.currentTimeMillis(); @@ -1995,7 +1999,8 @@ class AiPanel { if (ctx != null && ctx.ollamaMonitor != null && client.apiType() == LlmClient.ApiType.ollama) { // the Ollama tab shows this request with the timings Ollama returned ctx.ollamaMonitor.adoptEndpoint(client.endpointUrl()); - ctx.ollamaMonitor.recordRequest(model, response.usage(), latencyMs, response.stopReason()); + ctx.ollamaMonitor.recordRequest(model, response.usage(), latencyMs, response.stopReason(), + questionCounter, currentQuestion); } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java index 553e11d8ce71..bc56758e4c57 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java @@ -146,7 +146,22 @@ final class OllamaMonitor { */ record RequestEntry(Instant timestamp, RequestSource source, String routeId, String model, int inputTokens, int outputTokens, int cachedTokens, long prefillMs, long decodeMs, long loadMs, long totalMs, - String doneReason, long contextSize) { + String doneReason, long contextSize, int question, String questionText) { + + RequestEntry(Instant timestamp, RequestSource source, String routeId, String model, int inputTokens, + int outputTokens, int cachedTokens, long prefillMs, long decodeMs, long loadMs, long totalMs, + String doneReason, long contextSize) { + this(timestamp, source, routeId, model, inputTokens, outputTokens, cachedTokens, prefillMs, decodeMs, + loadMs, totalMs, doneReason, contextSize, 0, null); + } + + /** Requests of the same AI panel question (its tool-call steps) share this key; a route call stands alone. */ + String groupKey() { + if (source == RequestSource.ROUTE) { + return "route:" + timestamp.toEpochMilli() + ":" + routeId; + } + return "q" + question + ":" + (questionText != null ? questionText : ""); + } /** * Everything the model had in front of it. Ollama's {@code prompt_eval_count} is the whole prompt; the cached @@ -201,6 +216,131 @@ final class OllamaMonitor { } } + /** + * One AI panel question with all the requests it took (the model's tool calls each cost a request), oldest step + * first, or a single route call. Aggregates are what the user experienced for the whole question. + */ + record QuestionGroup(String key, RequestSource source, int question, String questionText, String routeId, + List<RequestEntry> steps) { + + RequestEntry first() { + return steps.get(0); + } + + RequestEntry last() { + return steps.get(steps.size() - 1); + } + + /** The largest prompt of the question: how far the context was pushed. */ + long promptTokens() { + long max = 0; + for (RequestEntry e : steps) { + max = Math.max(max, e.promptTokens()); + } + return max; + } + + long outputTokens() { + long sum = 0; + for (RequestEntry e : steps) { + sum += e.outputTokens(); + } + return sum; + } + + int cacheHitPercent() { + long prompt = 0; + long cached = 0; + for (RequestEntry e : steps) { + prompt += e.inputTokens(); + cached += e.cachedTokens(); + } + return prompt > 0 ? (int) Math.min(100, cached * 100 / prompt) : 0; + } + + int contextPercent() { + int max = -1; + for (RequestEntry e : steps) { + max = Math.max(max, e.contextPercent()); + } + return max; + } + + double prefillTokensPerSecond() { + long evaluated = 0; + long ms = 0; + for (RequestEntry e : steps) { + evaluated += e.evaluatedTokens(); + ms += e.prefillMs(); + } + return ms > 0 && evaluated > 0 ? evaluated * 1000.0 / ms : 0; + } + + double decodeTokensPerSecond() { + long out = 0; + long ms = 0; + for (RequestEntry e : steps) { + out += e.outputTokens(); + ms += e.decodeMs(); + } + return ms > 0 && out > 0 ? out * 1000.0 / ms : 0; + } + + /** Time to the first token of the first step: the wait before anything happened. */ + long ttftMs() { + return first().ttftMs(); + } + + boolean hasTimings() { + return first().hasTimings(); + } + + boolean coldStart() { + for (RequestEntry e : steps) { + if (e.coldStart()) { + return true; + } + } + return false; + } + + /** From the first request starting to the last one finishing. */ + long wallMs() { + long start = first().timestamp().toEpochMilli(); + long end = last().timestamp().toEpochMilli() + last().totalMs(); + return Math.max(end - start, last().totalMs()); + } + + String doneReason() { + return last().doneReason(); + } + } + + /** + * Groups requests (newest first) into questions: consecutive AI panel requests with the same question form one + * group with their steps oldest first; every route call is its own group. Groups come back newest first. + */ + static List<QuestionGroup> groupByQuestion(List<RequestEntry> newestFirst) { + List<QuestionGroup> groups = new ArrayList<>(); + int i = 0; + while (i < newestFirst.size()) { + RequestEntry head = newestFirst.get(i); + String key = head.groupKey(); + List<RequestEntry> steps = new ArrayList<>(); + int j = i; + while (j < newestFirst.size() && newestFirst.get(j).groupKey().equals(key) + && (head.source() == RequestSource.TUI || j == i)) { + steps.add(0, newestFirst.get(j)); + j++; + } + groups.add(new QuestionGroup( + key, head.source(), head.question(), head.questionText(), head.routeId(), + List.copyOf(steps))); + i = j; + } + return groups; + } + record SessionTotals(int requests, long inputTokens, long outputTokens, long cachedTokens, long prefillMs, long decodeMs, long loadMs, int coldStarts, int peakContextPercent, int compactions) { @@ -329,8 +469,18 @@ final class OllamaMonitor { } } - /** Records a request the TUI itself made to Ollama, with the usage and timings Ollama returned. */ + /** As {@link #recordRequest(String, LlmClient.TokenUsage, long, String, int, String)} without a question. */ void recordRequest(String model, LlmClient.TokenUsage usage, long latencyMs, String doneReason) { + recordRequest(model, usage, latencyMs, doneReason, 0, null); + } + + /** + * Records a request the TUI itself made to Ollama, with the usage and timings Ollama returned. {@code question} and + * {@code questionText} identify the AI panel turn so the tool-call steps of one question group together. + */ + void recordRequest( + String model, LlmClient.TokenUsage usage, long latencyMs, String doneReason, int question, + String questionText) { if (usage == null) { return; } @@ -339,7 +489,7 @@ final class OllamaMonitor { Instant.now(), RequestSource.TUI, null, model != null ? model : "unknown", usage.inputTokens(), usage.outputTokens(), usage.cachedTokens(), usage.prefillMillis(), usage.generationMillis(), usage.loadMillis(), total, doneReason, - contextSizeForNewRequest()); + contextSizeForNewRequest(), question, questionText != null ? questionText.strip() : null); synchronized (lock) { addRequest(entry); } @@ -1007,6 +1157,41 @@ final class OllamaMonitor { reqs.add(requestJson(e)); } root.put("requests", reqs); + JsonArray questions = new JsonArray(); + int q = 0; + for (QuestionGroup g : groupByQuestion(s.requests())) { + if (q++ >= requestLimit) { + break; + } + JsonObject jg = new JsonObject(); + jg.put("time", g.first().timestamp().toString()); + jg.put("source", g.source().name().toLowerCase(Locale.ROOT)); + if (g.routeId() != null) { + jg.put("routeId", g.routeId()); + } + if (g.question() > 0) { + jg.put("question", g.question()); + } + if (g.questionText() != null) { + jg.put("questionText", g.questionText()); + } + jg.put("model", g.last().model()); + jg.put("steps", g.steps().size()); + jg.put("promptTokens", g.promptTokens()); + jg.put("outputTokens", g.outputTokens()); + jg.put("cacheHitPercent", g.cacheHitPercent()); + jg.put("contextPercent", g.contextPercent()); + jg.put("prefillTokensPerSecond", round1(g.prefillTokensPerSecond())); + jg.put("decodeTokensPerSecond", round1(g.decodeTokensPerSecond())); + jg.put("ttftMs", g.ttftMs()); + jg.put("wallMs", g.wallMs()); + jg.put("coldStart", g.coldStart()); + if (g.doneReason() != null) { + jg.put("doneReason", g.doneReason()); + } + questions.add(jg); + } + root.put("questions", questions); if (s.lastPoll() != null) { root.put("lastPoll", s.lastPoll().toString()); } @@ -1034,6 +1219,12 @@ final class OllamaMonitor { r.put("coldStart", e.coldStart()); r.put("promptTokens", e.promptTokens()); r.put("evaluatedTokens", e.evaluatedTokens()); + if (e.question() > 0) { + r.put("question", e.question()); + } + if (e.questionText() != null) { + r.put("questionText", e.questionText()); + } r.put("contextSize", e.contextSize()); r.put("contextPercent", e.contextPercent()); if (e.doneReason() != null) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java index 7769b54d46f9..c7d9320d87f2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java @@ -21,8 +21,10 @@ import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; import dev.tamboui.layout.Constraint; import dev.tamboui.layout.Layout; @@ -47,6 +49,7 @@ import dev.tamboui.widgets.table.TableState; import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.HostStats; import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.LoadedModel; import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.ModelShape; +import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.QuestionGroup; import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.RequestEntry; import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.RequestSource; import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.SessionTotals; @@ -76,6 +79,10 @@ class OllamaTab extends AbstractTab { private final TableState tableState = new TableState(); private final ScrollbarState scrollState = new ScrollbarState(); private Rect lastTableArea; + /** Questions whose steps are unfolded in the request log. */ + private final Set<String> expandedGroups = new HashSet<>(); + /** What each table row is: a {@link QuestionGroup} header or a {@link RequestEntry} step, as last rendered. */ + private List<Object> rowRefs = List.of(); OllamaTab(MonitorContext ctx, OllamaMonitor monitor) { super(ctx); @@ -107,8 +114,25 @@ class OllamaTab extends AbstractTab { navigateDown(); return true; } + if (ke.isConfirm() || ke.isRight() || ke.isLeft()) { + QuestionGroup group = selectedGroup(); + if (group != null && group.steps().size() > 1) { + if (ke.isLeft()) { + expandedGroups.remove(group.key()); + selectGroupHeader(group); + } else if (ke.isRight()) { + expandedGroups.add(group.key()); + } else if (!expandedGroups.remove(group.key())) { + expandedGroups.add(group.key()); + } else { + selectGroupHeader(group); + } + } + return true; + } if (ke.isChar('r') && monitor != null) { monitor.reset(); + expandedGroups.clear(); tableState.select(0); return true; } @@ -119,6 +143,35 @@ class OllamaTab extends AbstractTab { return false; } + /** The question the selected row belongs to (a header row or one of its steps). */ + private QuestionGroup selectedGroup() { + Integer sel = tableState.selected(); + if (sel == null || sel < 0 || sel >= rowRefs.size()) { + return null; + } + Object ref = rowRefs.get(sel); + if (ref instanceof QuestionGroup g) { + return g; + } + if (ref instanceof RequestEntry e) { + for (int i = sel; i >= 0; i--) { + if (rowRefs.get(i) instanceof QuestionGroup g && g.steps().contains(e)) { + return g; + } + } + } + return null; + } + + private void selectGroupHeader(QuestionGroup group) { + for (int i = 0; i < rowRefs.size(); i++) { + if (rowRefs.get(i) == group) { + tableState.select(i); + return; + } + } + } + @Override public void navigateUp() { tableState.selectPrevious(); @@ -126,7 +179,7 @@ class OllamaTab extends AbstractTab { @Override public void navigateDown() { - int rows = monitor != null ? monitor.snapshot().requests().size() : 0; + int rows = rowRefs.size(); if (rows > 0) { tableState.selectNext(rows); } @@ -134,13 +187,13 @@ class OllamaTab extends AbstractTab { @Override public boolean handleMouseEvent(MouseEvent me, Rect area) { - int rows = monitor != null ? monitor.snapshot().requests().size() : 0; - return handleTableClick(me, lastTableArea, tableState, rows); + return handleTableClick(me, lastTableArea, tableState, rowRefs.size()); } @Override public void renderFooter(List<Span> spans) { hint(spans, TuiIcons.ARROW_UP + TuiIcons.ARROW_DOWN, "select"); + hint(spans, "Enter", "steps"); hint(spans, "r", "reset"); hintLast(spans, "F5", "refresh"); } @@ -457,28 +510,8 @@ class OllamaTab extends AbstractTab { private void renderRequests(Frame frame, Rect area, Snapshot s) { List<RequestEntry> requests = s.requests(); - List<Row> rows = new ArrayList<>(); - for (RequestEntry e : requests) { - rows.add(Row.from( - Cell.from(Span.styled(" " + TIME.format(e.timestamp()), Theme.muted())), - Cell.from(Span.styled(sourceLabel(e), e.source() == RequestSource.ROUTE - ? Theme.notice() - : Theme.info())), - Cell.from(Span.raw(e.model())), - rightCell(formatTokens(e.inputTokens()), 6), - rightCell(formatTokens(e.outputTokens()), 6), - rightCell(e.cachedTokens() > 0 ? formatTokens(e.cachedTokens()) : "-", 6, Theme.muted()), - rightCell(e.contextPercent() >= 0 ? e.contextPercent() + "%" : "-", 5, - e.contextPercent() >= 80 ? Theme.error() : e.contextPercent() >= 50 ? Theme.warning() - : Style.EMPTY), - rightCell(e.prefillMs() > 0 ? formatRate(e.prefillTokensPerSecond()) : "-", 8, Theme.info()), - rightCell(e.decodeMs() > 0 ? formatRate(e.decodeTokensPerSecond()) : "-", 8, Theme.success()), - rightCell(e.hasTimings() ? formatSeconds(e.ttftMs()) : "-", 7, - e.coldStart() ? Theme.warning() : Style.EMPTY), - rightCell(e.totalMs() > 0 ? formatSeconds(e.totalMs()) : "-", 7), - Cell.from(Span.styled(" " + (e.doneReason() != null ? e.doneReason() : ""), Theme.muted())))); - } - if (rows.isEmpty()) { + if (requests.isEmpty()) { + rowRefs = List.of(); lastTableArea = area; frame.renderWidget(Paragraph.builder() .text(Text.from(List.of( @@ -491,15 +524,39 @@ class OllamaTab extends AbstractTab { .build(), area); return; } + + List<QuestionGroup> groups = OllamaMonitor.groupByQuestion(requests); + // fixed columns plus borders and the highlight gutter; the rest is the question column + int questionWidth = Math.max(12, area.width() - FIXED_COLUMNS_WIDTH); + List<Row> rows = new ArrayList<>(); + List<Object> refs = new ArrayList<>(); + for (QuestionGroup g : groups) { + boolean multi = g.steps().size() > 1; + boolean expanded = multi && expandedGroups.contains(g.key()); + rows.add(questionRow(g, multi, expanded, questionWidth)); + refs.add(g); + if (expanded) { + int n = 1; + for (RequestEntry e : g.steps()) { + rows.add(stepRow(e, n++, g.steps().size())); + refs.add(e); + } + } + } + rowRefs = refs; + + String title = " Requests (" + groups.size() + (groups.size() == 1 ? " question, " : " questions, ") + + requests.size() + (requests.size() == 1 ? " request)" : " requests)") + + " tok/s for prefill and decode · CTX = prompt share of the context window "; Table table = Table.builder() .rows(rows) .header(Row.from( Cell.from(Span.styled(" TIME", Style.EMPTY.bold())), Cell.from(Span.styled("SOURCE", Style.EMPTY.bold())), - Cell.from(Span.styled("MODEL", Style.EMPTY.bold())), + Cell.from(Span.styled("QUESTION", Style.EMPTY.bold())), rightCell("IN", 6, Style.EMPTY.bold()), rightCell("OUT", 6, Style.EMPTY.bold()), - rightCell("CACHED", 6, Style.EMPTY.bold()), + rightCell("CACHE", 6, Style.EMPTY.bold()), rightCell("CTX", 5, Style.EMPTY.bold()), rightCell("PREFILL", 8, Style.EMPTY.bold()), rightCell("DECODE", 8, Style.EMPTY.bold()), @@ -507,7 +564,7 @@ class OllamaTab extends AbstractTab { rightCell("TOTAL", 7, Style.EMPTY.bold()), Cell.from(Span.styled(" REASON", Style.EMPTY.bold())))) .widths( - Constraint.length(9), + Constraint.length(10), Constraint.length(18), Constraint.fill(), Constraint.length(6), @@ -521,16 +578,78 @@ class OllamaTab extends AbstractTab { Constraint.length(12)) .highlightStyle(Theme.selectionBg()) .highlightSpacing(Table.HighlightSpacing.ALWAYS) - .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) - .title(" Requests (" + requests.size() - + ") tok/s for prefill and decode · CTX = prompt share of the context window ") - .build()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build()) .build(); lastTableArea = area; frame.renderStatefulWidget(table, area, tableState); renderTableScrollbar(frame, area, tableState, scrollState, rows.size()); } + private static final int FIXED_COLUMNS_WIDTH = 10 + 18 + 6 + 6 + 6 + 5 + 8 + 8 + 7 + 7 + 12 + 6; + + /** One question (or route call) on one line: the question text cut to fit, then the whole-question figures. */ + private static Row questionRow(QuestionGroup g, boolean multi, boolean expanded, int questionWidth) { + String marker = multi ? (expanded ? TuiIcons.MORE_CHEVRON : TuiIcons.ARROW_RIGHT) : " "; + String source; + Style sourceStyle; + if (g.source() == RequestSource.ROUTE) { + source = g.routeId() != null ? "route:" + g.routeId() : "route"; + sourceStyle = Theme.notice(); + } else { + source = (g.question() > 0 ? "#" + g.question() : "tui") + (multi ? " ×" + g.steps().size() : ""); + sourceStyle = Theme.info(); + } + String text = g.questionText() != null && !g.questionText().isBlank() + ? firstLine(g.questionText()) + : g.last().model(); + int ctx = g.contextPercent(); + return Row.from( + Cell.from(Span.styled(marker + TIME.format(g.first().timestamp()), Theme.muted())), + Cell.from(Span.styled(source, sourceStyle)), + Cell.from(Span.styled(TuiHelper.truncate(text, questionWidth), Theme.label())), + rightCell(formatTokens(g.promptTokens()), 6), + rightCell(formatTokens(g.outputTokens()), 6), + rightCell(g.cacheHitPercent() + "%", 6, Theme.muted()), + rightCell(ctx >= 0 ? ctx + "%" : "-", 5, + ctx >= 80 ? Theme.error() : ctx >= 50 ? Theme.warning() : Style.EMPTY), + rightCell(g.prefillTokensPerSecond() > 0 ? formatRate(g.prefillTokensPerSecond()) : "-", 8, + Theme.info()), + rightCell(g.decodeTokensPerSecond() > 0 ? formatRate(g.decodeTokensPerSecond()) : "-", 8, + Theme.success()), + rightCell(g.hasTimings() ? formatSeconds(g.ttftMs()) : "-", 7, + g.coldStart() ? Theme.warning() : Style.EMPTY), + rightCell(g.wallMs() > 0 ? formatSeconds(g.wallMs()) : "-", 7), + Cell.from(Span.styled(" " + (g.doneReason() != null ? g.doneReason() : ""), Theme.muted()))); + } + + /** One request of an unfolded question: step number, the model, and that request's own figures. */ + private static Row stepRow(RequestEntry e, int step, int of) { + int ctx = e.contextPercent(); + return Row.from( + Cell.from(Span.styled(" " + TIME.format(e.timestamp()), Theme.muted().dim())), + Cell.from(Span.styled(" step " + step + "/" + of, Theme.muted())), + Cell.from(Span.styled(e.model(), Theme.muted().dim())), + rightCell(formatTokens(e.inputTokens()), 6, Theme.muted()), + rightCell(formatTokens(e.outputTokens()), 6, Theme.muted()), + rightCell(e.cachedTokens() > 0 ? e.cacheHitPercent() + "%" : "-", 6, Theme.muted()), + rightCell(ctx >= 0 ? ctx + "%" : "-", 5, Theme.muted()), + rightCell(e.prefillMs() > 0 ? formatRate(e.prefillTokensPerSecond()) : "-", 8, Theme.muted()), + rightCell(e.decodeMs() > 0 ? formatRate(e.decodeTokensPerSecond()) : "-", 8, Theme.muted()), + rightCell(e.hasTimings() ? formatSeconds(e.ttftMs()) : "-", 7, + e.coldStart() ? Theme.warning() : Theme.muted()), + rightCell(e.totalMs() > 0 ? formatSeconds(e.totalMs()) : "-", 7, Theme.muted()), + Cell.from(Span.styled(" " + (e.doneReason() != null ? e.doneReason() : ""), Theme.muted().dim()))); + } + + static String firstLine(String text) { + String t = text.strip(); + int nl = t.indexOf('\n'); + if (nl >= 0) { + t = t.substring(0, nl).strip() + " …"; + } + return t.replace('\t', ' '); + } + // ---- MCP / table data ---- @Override diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md index e63b78c069fb..bc3bc9ae11f0 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md @@ -108,22 +108,28 @@ Shown when Ollama runs on this machine: ## Requests -One line per request, newest first: - -| Column | Meaning | -|--------|---------| -| TIME | When the request started | -| SOURCE | `tui` for the AI panel, `route:<id>` for a Camel route | -| MODEL | The model that answered | -| IN | Prompt tokens, the whole prompt the model saw (CACHED is the part of it served from cache) | -| OUT | Tokens generated | -| CACHED | Prompt tokens served from Ollama's cache (`-` when none) | -| CTX | Share of the context window the prompt filled (IN against the window that served it) | -| PREFILL | Evaluated prompt tokens per second (IN minus CACHED over the prefill time) | -| DECODE | Generated tokens per second | -| TTFT | Time to first token (load plus prefill), shown in yellow after a cold start | -| TOTAL | Whole request as Ollama measured it | -| REASON | Why generation stopped: `stop`, `length` (hit the token limit), `tool_calls` | +One line per question, newest first. A question asked in the AI panel +usually costs several requests, because every tool call the model makes +is answered in a new request with the whole prompt again; the line shows +the question text (cut to fit) and the figures for the question as a +whole. Press **Enter** (or **→**) on a question with `×N` in the SOURCE +column to unfold its steps, **←** to fold them again. A call made by a +Camel route is one line of its own. + +| Column | Question line | Step line | +|--------|---------------|-----------| +| TIME | When the first request started | When the request started | +| SOURCE | `#7 ×10`: question number and request count, or `route:<id>` | `step 3/10` | +| QUESTION | The question, first line cut to fit (the model for a route) | The model that answered | +| IN | The largest prompt of the question: how far the context was pushed | Prompt tokens, the whole prompt the model saw | +| OUT | Tokens generated across all steps | Tokens generated | +| CACHE | Share of all prompt tokens served from Ollama's cache | Share of this prompt served from cache (`-` when none) | +| CTX | Highest share of the context window reached | Share of the context window this prompt filled | +| PREFILL | Evaluated prompt tokens per second across the steps (prompt minus cached over the prefill time) | Same for this request | +| DECODE | Generated tokens per second across the steps | Same for this request | +| TTFT | Time to the first token of the first step (load plus prefill), yellow after a cold start | Time to first token of this request | +| TOTAL | From the first request starting to the last finishing: what you waited | This request as Ollama measured it | +| REASON | Why the last step stopped: `stop`, `length` (hit the token limit) | `tool_calls` for every step but the last | Route requests show `-` for prefill, decode and TTFT because the GenAI span carries tokens and duration only. @@ -139,7 +145,9 @@ tab says so and keeps the rest. | Key | Action | |-----|--------| -| ↑ / ↓ | Select a request | +| ↑ / ↓ | Select a question or step | +| Enter / → | Unfold the steps of a question | +| ← | Fold them again | | r | Reset the request log and session totals | | F5 | Refresh now | diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java index 60589f1933e6..04aeec6de046 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java @@ -204,6 +204,60 @@ class OllamaMonitorTest { assertEquals(0, history[0]); } + @Test + void requestsGroupIntoQuestionsWithStepsOldestFirst() { + OllamaMonitor monitor = new OllamaMonitor(); + monitor.updateModels(List.of(new OllamaMonitor.LoadedModel( + "m", "f", "35.5B", "Q4_K_M", 1, 1, 32_768, null, null))); + // question 7: three steps (two tool calls, then the answer); the prompt grows and the cache warms + // the first step loaded the model: 1.1 s load plus 5.1 s prefill + monitor.recordRequest("m", new LlmClient.TokenUsage(4_500, 33, 4_533, 0, 5_100, 700, 1_100, 6_900), 0, + "tool_calls", 7, "how many messages have camel done"); + monitor.recordRequest("m", new LlmClient.TokenUsage(4_700, 78, 4_778, 4_500, 500, 1_600, 0, 1_900), 0, + "tool_calls", 7, "how many messages have camel done"); + monitor.recordRequest("m", new LlmClient.TokenUsage(7_000, 106, 7_106, 4_700, 3_600, 1_900, 0, 5_500), 0, + "stop", 7, "how many messages have camel done"); + // question 8: a single request + monitor.recordRequest("m", new LlmClient.TokenUsage(7_200, 40, 7_240, 7_000, 300, 700, 0, 1_000), 0, + "stop", 8, "thanks"); + // and a route call + monitor.ingestSpans(List.of(genAiSpan("s9", "ollama", "m", "chat-route", 412, 180, 4100))); + + List<OllamaMonitor.QuestionGroup> groups = OllamaMonitor.groupByQuestion(monitor.snapshot().requests()); + assertEquals(3, groups.size()); + OllamaMonitor.QuestionGroup route = groups.get(0); + assertEquals(RequestSource.ROUTE, route.source()); + assertEquals(1, route.steps().size()); + assertEquals("chat-route", route.routeId()); + + OllamaMonitor.QuestionGroup q8 = groups.get(1); + assertEquals(8, q8.question()); + assertEquals("thanks", q8.questionText()); + assertEquals(1, q8.steps().size()); + + OllamaMonitor.QuestionGroup q7 = groups.get(2); + assertEquals(7, q7.question()); + assertEquals(3, q7.steps().size()); + assertEquals(33, q7.steps().get(0).outputTokens(), "steps are oldest first"); + assertEquals(7_000, q7.promptTokens()); + assertEquals(33 + 78 + 106, q7.outputTokens()); + // 9,200 of 16,200 prompt tokens came from the cache + assertEquals(56, q7.cacheHitPercent()); + assertEquals(21, q7.contextPercent()); + assertEquals(6_200, q7.ttftMs()); + assertTrue(q7.coldStart()); + assertEquals("stop", q7.doneReason()); + // evaluated 4,500 + 200 + 2,300 tokens over 9.2 s of prefill + assertEquals(7_000 * 1000.0 / 9_200, q7.prefillTokensPerSecond(), 0.5); + assertTrue(q7.wallMs() >= 5_500); + + JsonArray questions = (JsonArray) monitor.toJson(10).get("questions"); + assertEquals(3, questions.size()); + JsonObject jq7 = (JsonObject) questions.get(2); + assertEquals(3, jq7.get("steps")); + assertEquals("how many messages have camel done", jq7.get("questionText")); + } + @Test void availabilityFollowsTheServer() { OllamaMonitor monitor = new OllamaMonitor(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java index d90b43d5dabf..8a77262cbae3 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java @@ -22,6 +22,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; import org.apache.camel.dsl.jbang.core.commands.LlmClient; import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.GpuStats; import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.HostStats; @@ -110,7 +113,7 @@ class OllamaTabRenderTest { assertTrue(rendered.contains("llama-server"), rendered); assertTrue(rendered.contains("ollama serve"), rendered); // request log - assertTrue(rendered.contains("Requests (1)"), rendered); + assertTrue(rendered.contains("Requests (1 question, 1 request)"), rendered); assertTrue(rendered.contains("CTX"), rendered); assertTrue(rendered.contains("PREFILL"), rendered); assertTrue(rendered.contains("DECODE"), rendered); @@ -155,6 +158,38 @@ class OllamaTabRenderTest { assertTrue(rendered.contains("2 installed: qwen3.6:35b-a3b, llama3.2:latest"), rendered); } + @Test + void requestsAreGroupedPerQuestionAndUnfoldOnEnter() { + localServerWithModel(); + String question = "how many messages have camel done\nand are any failing?"; + monitor.recordRequest("qwen3.6:35b-a3b", new LlmClient.TokenUsage(4_500, 33, 4_533, 0, 620, 700, 0, 1_320), 0, + "tool_calls", 7, question); + monitor.recordRequest("qwen3.6:35b-a3b", new LlmClient.TokenUsage(4_700, 78, 4_778, 4_500, 50, 1_600, 0, 1_700), 0, + "tool_calls", 7, question); + monitor.recordRequest("qwen3.6:35b-a3b", new LlmClient.TokenUsage(7_000, 106, 7_106, 4_700, 360, 1_900, 0, 2_300), 0, + "stop", 7, question); + + OllamaTab tab = new OllamaTab(ctx, monitor); + String rendered = TuiTestHelper.renderToString(tab, 200, 40); + assertTrue(rendered.contains("Requests (1 question, 3 requests)"), rendered); + assertTrue(rendered.contains("#7 ×3"), rendered); + assertTrue(rendered.contains("how many messages have camel done …"), rendered); + assertTrue(rendered.contains("QUESTION"), rendered); + assertFalse(rendered.contains("step 1/3"), rendered); + + // Enter on the question unfolds its steps + tab.navigateDown(); + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)); + rendered = TuiTestHelper.renderToString(tab, 200, 40); + assertTrue(rendered.contains("step 1/3"), rendered); + assertTrue(rendered.contains("step 3/3"), rendered); + + // and folds them again + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)); + rendered = TuiTestHelper.renderToString(tab, 200, 40); + assertFalse(rendered.contains("step 1/3"), rendered); + } + @Test void routeRequestsShowTheirRouteId() { localServerWithModel(); @@ -164,7 +199,7 @@ class OllamaTabRenderTest { String rendered = TuiTestHelper.renderToString(new OllamaTab(ctx, monitor), 180, 40); assertTrue(rendered.contains("route:chat-route"), rendered); - assertTrue(rendered.contains("Requests (2)"), rendered); + assertTrue(rendered.contains("Requests (2 questions, 2 requests)"), rendered); } @Test @@ -205,7 +240,8 @@ class OllamaTabRenderTest { void helpTextExplainsThePhasesAndColumns() { String help = new OllamaTab(ctx, monitor).getHelpText(); assertTrue(help != null && help.contains("TTFT"), help); - for (String term : List.of("Prefill", "Decode", "cold", "cache hit", "speculative", "CACHED", "REASON")) { + for (String term : List.of("Prefill", "Decode", "cold", "cache hit", "speculative", "CACHE", "QUESTION", + "REASON")) { assertTrue(help.contains(term), "help should explain " + term); } }
