From 40979e1aab54f2fa8bb8686020ed9e33ee2e68e9 Mon Sep 17 00:00:00 2001 From: sumit Date: Sat, 15 Aug 2026 11:36:06 +0530 Subject: [PATCH 1/6] feat(dashboards): stream dashboard builds progressively, fix duplicate widget queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboards now build visibly instead of showing a blank canvas for the whole generation: the agent emits a shell block plus one verified widget block at a time, each streamed to the UI and mounted into the live iframe as soon as it's ready. The chat trace also translates raw tool calls into plain-English progress ("Checking the numbers…") instead of showing SQL or internal tool names. Also fixes a real bug this surfaced: when a build/edit finished without self-review changes, the canvas was reloading and re-running every widget's query a second time, duplicating entries in the Queries panel. Co-Authored-By: Claude Sonnet 5 --- agent/skills/dashboard-design/SKILL.md | 171 +++++++++++-- .../DashboardGenerationController.java | 11 + .../com/dbaagent/service/AgentChatClient.java | 105 +++++++- .../service/DashboardAgentService.java | 233 +++++++++++++++--- src/components/DashboardArtifact.jsx | 87 ++++++- .../sections/DashboardWorkspace.jsx | 62 ++++- .../sections/DashboardWorkspace.module.css | 39 +++ src/lib/api/client.js | 3 +- src/lib/dashboardGenerator.js | 7 +- src/lib/stores/useDashboardChatStore.js | 108 +++++++- 10 files changed, 735 insertions(+), 91 deletions(-) diff --git a/agent/skills/dashboard-design/SKILL.md b/agent/skills/dashboard-design/SKILL.md index 6a4e4e7..d7e34c2 100644 --- a/agent/skills/dashboard-design/SKILL.md +++ b/agent/skills/dashboard-design/SKILL.md @@ -1,7 +1,7 @@ --- name: dashboard-design -description: Design and code a self-contained HTML dashboard for DeepSQL — ground on the schema, verify SQL, then write a beautiful single-file dashboard that loads data via the deepsql.query bridge. -version: 2.1.0 +description: Design and code a self-contained HTML dashboard for DeepSQL — ground on the schema, verify SQL, then emit it as a shell plus one verified widget block at a time so the canvas builds progressively. +version: 3.0.0 platforms: [linux, macos, windows] metadata: hermes: @@ -15,7 +15,12 @@ Use when asked to build or edit a dashboard (the task says "build a self-contain ## The runtime you build against -Your output is ONE self-contained HTML document. It runs inside a sandboxed iframe with a bridge already injected: +Your output is a **shell block, then one widget block per KPI/chart** — not a single HTML +document. The parent assembles them (each widget's markup+script drops into its own +`[data-widget=id]` slot in the shell) and renders each widget into the live canvas the moment +its block closes, well before your whole turn finishes — so the user watches the dashboard +build piece by piece instead of staring at a blank screen for the whole generation. Both kinds +of block run inside the SAME sandboxed iframe with a bridge already injected: ```js deepsql.connectionId // this connection's id (string) @@ -44,28 +49,34 @@ plain-business-language heading) so the expanded overlay has something to show a not add your own zoom/expand/fullscreen button, modal, or lightbox; one already exists per chart. Hard rules: -- Inline everything — one ` -

Booking Momentum

Daily new properties and booking volume for the selected dates.

-
-
-
+
+
+

Daily performance

+

Booking Momentum

+

Daily new properties and booking volume across the selected period.

+
+
+
+ +
+ + + +
+
+
+
+ +
+
+
+
+
+
+
``` +One block per widget — its own markup AND the script that queries and fills it in. A widget whose data is date-sensitive reads `window.__dateRange` on load and re-queries on the shell's `dsql:daterange` event; a widget that isn't date-sensitive (e.g. a lifetime total) simply ignores the control: + +```dashboard-widget id="new-properties" +

New properties

+

+ +``` + +```dashboard-widget id="bookings-trend" +

Bookings over time

+
+ +``` + ## Guardrails - Read-only SELECT/WITH only (the bridge rejects anything else anyway). - Self-contained: no external network, no imported fonts, no inline data dumps — query live. - No internals visible to the user (see the security section above) — user-facing error text stays generic ("Couldn't load this metric."). -- Return the FULL document every time (including on edits), inside ONE ```html block, with no prose after it. +- Emit the shell ONCE, then one `dashboard-widget` block per widget (or a corrected re-emit of one, same id, if self-review catches a problem) — never wrap everything back into a single ```html block. On an EDIT, re-emit the full shell (even for widgets you aren't changing, their slots must still exist) and every widget's block, including unchanged ones — the assembled document is built fresh from what you emit this turn, not merged with the prior one. diff --git a/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java b/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java index 8006730..815faaf 100644 --- a/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java +++ b/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java @@ -140,6 +140,17 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) { } catch (IOException io) { throw new ClientGoneException(io); } + }, + (kind, id, html) -> { + try { + Map data = new java.util.HashMap<>(); + data.put("kind", kind); + data.put("id", id); + data.put("html", html); + emitter.send(SseEmitter.event().name("chunk").data(data)); + } catch (IOException io) { + throw new ClientGoneException(io); + } }); // Chat-only replies (greetings / tool questions) must not share the // `done` event with a real artifact — the FE's done handler always diff --git a/backend/src/main/java/com/dbaagent/service/AgentChatClient.java b/backend/src/main/java/com/dbaagent/service/AgentChatClient.java index 491389e..2f4a216 100644 --- a/backend/src/main/java/com/dbaagent/service/AgentChatClient.java +++ b/backend/src/main/java/com/dbaagent/service/AgentChatClient.java @@ -22,6 +22,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Synchronous client for the DeepSQL Agent webui API, reached over the compose @@ -144,6 +146,26 @@ private void switchProfile(String profile) throws Exception { * assembled assistant text and the tool steps it ran. */ public AgentReply sendAndAwait(String sessionId, String message) { + return sendAndAwait(sessionId, message, null); + } + + /** Live per-tool-call notifications ("SQL · select …", "skill · …") as they happen. */ + public interface ToolStepListener { + void onToolStep(String label); + } + + /** A completed dashboard-shell or dashboard-widget fenced block, as soon as it closes. */ + public record ArtifactChunk(String kind, String id, String html) { } + + public interface ArtifactChunkListener { + void onChunk(ArtifactChunk chunk); + } + + public AgentReply sendAndAwait(String sessionId, String message, ToolStepListener toolSteps) { + return sendAndAwait(sessionId, message, toolSteps, null); + } + + public AgentReply sendAndAwait(String sessionId, String message, ToolStepListener toolSteps, ArtifactChunkListener chunks) { String streamId; try { JsonNode started = postJson("/api/chat/start", Map.of( @@ -156,10 +178,10 @@ public AgentReply sendAndAwait(String sessionId, String message) { } catch (Exception e) { return AgentReply.fail("could not start agent turn: " + describe(e)); } - return consumeStream(streamId); + return consumeStream(streamId, toolSteps, chunks); } - private AgentReply consumeStream(String streamId) { + private AgentReply consumeStream(String streamId, ToolStepListener toolStepListener, ArtifactChunkListener chunkListener) { String url = webuiUrl + "/api/chat/stream?stream_id=" + URLEncoder.encode(streamId, StandardCharsets.UTF_8); HttpRequest req = HttpRequest.newBuilder(URI.create(url)) .header("Accept", "text/event-stream") @@ -171,6 +193,13 @@ private AgentReply consumeStream(String streamId) { List toolSteps = new ArrayList<>(); boolean ended = false; String streamError = null; + // Scan position into `answer` for chunk detection — advances past each + // extracted dashboard-shell/dashboard-widget block so a closed fence is + // never re-matched, and resets with the buffer on `tool` (a chunk only + // ever appears in the FINAL answer, after the last tool call, per the + // skill's "no tool calls after it" contract — so a reset never splits one). + int[] chunkScanPos = { 0 }; + int[] sqlStepCount = { 0 }; try { HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream()); if (resp.statusCode() / 100 != 2) { @@ -186,15 +215,25 @@ private AgentReply consumeStream(String streamId) { String data = line.substring(5).trim(); if (event == null) continue; switch (event) { - case "token" -> answer.append(textField(data, "text")); + case "token" -> { + answer.append(textField(data, "text")); + if (chunkListener != null) chunkScanPos[0] = scanForChunks(answer, chunkScanPos[0], chunkListener); + } case "tool" -> { String s = toolStep(data); - if (s != null) toolSteps.add(s); + if (s != null) { + toolSteps.add(s); + if (toolStepListener != null) { + String natural = naturalLanguageStep(data, sqlStepCount); + if (natural != null) toolStepListener.onToolStep(natural); + } + } // Everything streamed before a tool call is interim // reasoning ("I'm pulling the schema…"). Channel users // only want the final answer, so drop it — the text // after the LAST tool is the real reply. answer.setLength(0); + chunkScanPos[0] = 0; } case "error", "apperror" -> { String err = textField(data, "message"); @@ -274,6 +313,30 @@ private String text(JsonNode node) { return (node == null || node.isMissingNode() || node.isNull()) ? null : node.asText(null); } + // Matches a CLOSED fence only — requires the trailing ``` on its own line, so a + // fence still being streamed (no closing marker yet) never matches and is left + // for the next token to complete. Group 2 (id="...") is present only on a widget + // block. DOTALL so the body can span many token-appended lines. + private static final Pattern CHUNK_FENCE = Pattern.compile( + "```dashboard-(shell|widget)(?:\\s+id=\"([^\"]+)\")?\\s*\\n(.*?)\\n```", + Pattern.DOTALL); + + /** Scans answer[fromPos..] for complete chunks, firing the listener for each. Returns the new scan position. */ + private int scanForChunks(StringBuilder answer, int fromPos, ArtifactChunkListener listener) { + Matcher m = CHUNK_FENCE.matcher(answer).region(fromPos, answer.length()); + int pos = fromPos; + while (m.find()) { + String kind = m.group(1); + String id = m.group(2); + String html = m.group(3); + try { + listener.onChunk(new ArtifactChunk(kind, id, html)); + } catch (Exception ignored) { } + pos = m.end(); + } + return pos; + } + private String textField(String data, String field) { try { return objectMapper.readTree(data).path(field).asText(""); @@ -297,4 +360,38 @@ private String toolStep(String data) { return null; } } + + // Present-progressive phrase per tool, for a user-facing live trace — never the + // raw tool name, query text, or a table/column name (the dashboard artifact + // itself is held to the same no-internals-visible bar; the trace shouldn't leak + // what the artifact is required to hide). + private static final String[] SQL_STEP_PHRASES = { + "Checking the numbers…", "Double-checking the data…", "Verifying a query against your data…", + "Confirming the figures…", "Running another check…", + }; + + // sqlStepCount varies the SQL-verification phrase across calls so a multi-widget + // build doesn't repeat one line 6 times — passed in per-call via int[] (a single- + // element mutable box) rather than an instance field, since this @Service is a + // shared singleton and an instance field would race across concurrent turns from + // different users/dashboards. + private String naturalLanguageStep(String data, int[] sqlStepCount) { + try { + JsonNode d = objectMapper.readTree(data); + String name = d.path("name").asText(""); + JsonNode args = d.path("args"); + if (!args.path("query").asText("").isBlank()) { + return SQL_STEP_PHRASES[sqlStepCount[0]++ % SQL_STEP_PHRASES.length]; + } + if ("skill_view".equals(name)) return "Loading dashboard-building expertise…"; + return switch (name.replaceFirst("^mcp_deepsql_", "")) { + case "get_brain_context", "list_business_rules" -> "Reviewing your business rules…"; + case "get_schema", "get_relationships" -> "Understanding your database structure…"; + case "execute_sql" -> SQL_STEP_PHRASES[sqlStepCount[0]++ % SQL_STEP_PHRASES.length]; + default -> null; // an unrecognized/future tool stays silent rather than leaking its raw name + }; + } catch (Exception e) { + return null; + } + } } diff --git a/backend/src/main/java/com/dbaagent/service/DashboardAgentService.java b/backend/src/main/java/com/dbaagent/service/DashboardAgentService.java index a361eed..0a85f08 100644 --- a/backend/src/main/java/com/dbaagent/service/DashboardAgentService.java +++ b/backend/src/main/java/com/dbaagent/service/DashboardAgentService.java @@ -25,18 +25,27 @@ * Dashboard generator — the embedded DeepSQL Agent as a coding agent * (customized Hermes runtime; see agent/README.md). * - *

The agent doesn't fill in a rigid spec anymore. It writes the whole - * dashboard as a single self-contained HTML document — any layout, filters, - * date pickers, chart types, styling it wants, coded directly — after grounding - * on the brain/schema and verifying every query with {@code execute_sql}. The - * artifact fetches data at runtime through the injected {@code deepsql.query(sql)} - * bridge (see DashboardQueryController), so it never holds DB creds and every - * query stays read-only + access-scoped. + *

The agent doesn't fill in a rigid spec. It designs a dashboard freely — + * any layout, filters, date pickers, chart types, styling it wants — after + * grounding on the brain/schema and verifying every widget's query with + * {@code execute_sql}. The artifact fetches data at runtime through the + * injected {@code deepsql.query(sql)} bridge (see DashboardQueryController), so + * it never holds DB creds and every query stays read-only + access-scoped. + * + *

The agent emits progressively: one {@code dashboard-shell} fenced block + * (page chrome + named, empty widget slots), then one {@code dashboard-widget} + * block per KPI/chart, each only after ITS OWN query is verified. A + * {@link ChunkListener} fires per block as it closes — well before the whole + * turn ends — so DashboardGenerationController can stream each piece to the + * UI as it's ready instead of the canvas staying blank for the whole build. + * {@link #assembleFromChunks} substitutes each widget into its shell slot to + * produce the final document once the turn completes; {@link #extractHtml} + * falls back to the older single-block contract if no shell/widget fences are + * present at all, so a reply that predates this contract still works. * *

The old JSON-spec contract (metrics/charts/tables + a {{placeholder}} * substitution engine + a fixed renderer) is gone: it couldn't express real SQL - * (e.g. a Unix-epoch date filter) and boxed the agent in. This broker just runs - * one agent turn with the artifact contract, extracts the HTML, and returns it. + * (e.g. a Unix-epoch date filter) and boxed the agent in. */ @Service public class DashboardAgentService { @@ -48,6 +57,17 @@ public interface StepListener { void step(String type, String message); } + /** + * Progressive-render sink: fired the instant the agent finishes a + * dashboard-shell or dashboard-widget block (before the whole turn ends), + * so the canvas can fill in piece by piece. NOOP for callers that only want + * the final assembled artifact (the blocking /generate endpoint). + */ + public interface ChunkListener { + ChunkListener NOOP = (kind, id, html) -> { }; + void chunk(String kind, String id, String html); + } + /** Artifact spec version stored in saved_dashboards.dashboardConfig. */ private static final int ARTIFACT_VERSION = 3; private static final int MAX_HTML_CHARS = 400_000; @@ -71,8 +91,14 @@ public DashboardAgentService(ObjectMapper objectMapper, } public Map generate(String connectionId, String prompt, Object currentConfig, StepListener listener) { + return generate(connectionId, prompt, currentConfig, listener, null); + } + + public Map generate(String connectionId, String prompt, Object currentConfig, + StepListener listener, ChunkListener chunkListener) { List> trace = new ArrayList<>(); StepListener l = listener == null ? StepListener.NOOP : listener; + ChunkListener c = chunkListener == null ? ChunkListener.NOOP : chunkListener; String username = accessControlService.requireCurrentUsername(); String profile = agentBridgeService.ensureProfileForUser(username, connectionId); @@ -107,8 +133,14 @@ public Map generate(String connectionId, String prompt, Object c emit(l, trace, "grounding", "Handing off to the DeepSQL agent…"); emit(l, trace, "planning", "Agent is grounding, writing SQL, and coding the dashboard…"); + // Without this, the UI showed nothing but the "planning" line above for the + // whole 3-4 minute build. Each tool call (schema lookup, a verified SQL + // query, a skill invocation) now becomes its own step, so the trace keeps + // moving instead of looking stuck. AgentChatClient.AgentReply reply = agentChatClient.sendAndAwait( - sessionId, buildTask(connectionId, prompt, currentConfig)); + sessionId, buildTask(connectionId, prompt, currentConfig), + toolLabel -> emit(l, trace, "sql", truncateStepLabel(toolLabel)), + chunk -> c.chunk(chunk.kind(), chunk.id(), chunk.html())); if (!reply.ok()) { throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "The agent couldn't build the dashboard: " + (reply.error() == null ? "it ended early" : reply.error())); @@ -186,9 +218,10 @@ private String buildChatTask(String prompt) { private String buildTask(String connectionId, String prompt, Object currentConfig) { StringBuilder sb = new StringBuilder(); - sb.append("Build a beautiful, self-contained, read-only BI dashboard as a SINGLE HTML document for ") - .append("DeepSQL connection ").append(connectionId) - .append(". Load your `dashboard-design` skill and follow it.\n\n"); + sb.append("Build a beautiful, self-contained, read-only BI dashboard for DeepSQL connection ") + .append(connectionId) + .append(", emitted progressively as a shell block plus one widget block per KPI/chart. ") + .append("Load your `dashboard-design` skill and follow it.\n\n"); sb.append("User request:\n").append(prompt == null ? "" : prompt.trim()).append("\n\n"); String currentHtml = currentHtml(currentConfig); @@ -210,24 +243,74 @@ Do NOT hardcode result data, do NOT use fetch()/XHR/WebSockets or any external U 1. Ground: get_brain_context, get_schema, list_business_rules, get_relationships. Obey business rules about which table/column/filter/currency a concept uses — quote them, don't guess a similar table. 2. Design the dashboard from the request: KPIs, charts, tables, and any filters/date pickers asked for. - Write ONE real, correct, read-only SELECT per widget (table-qualified). There is NO placeholder - convention — you write normal SQL. For a Unix-epoch date column, filter on the epoch directly - (e.g. col BETWEEN UNIX_TIMESTAMP('2026-07-01') AND UNIX_TIMESTAMP('2026-07-08')); build such SQL - in JS from the picker's values and pass the finished string to deepsql.query(). - 3. VERIFY every query with execute_sql and READ the rows: date windows bounded and inside range (never - future), KPI value types right (a name is text, money is currency), totals plausible vs a COUNT. - Fix and re-run until correct. - 4. INTENT CHECKLIST — before emitting, confirm EVERY explicit ask is satisfied (each chart, each metric, - and each UI control like a date range picker with the requested default, e.g. today). - 5. Write the single HTML document: inline +

+
...
+
+
+
+
+
+
+
+
+ + ``` + Pick a short, stable, kebab-case id per widget (e.g. "revenue-total") — you will reuse this + EXACT id in that widget's own block below. The shell itself has no + ``` + A widget's