Skip to content

Commit 40979e1

Browse files
notSumit25claude
andcommitted
feat(dashboards): stream dashboard builds progressively, fix duplicate widget queries
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 <noreply@anthropic.com>
1 parent 471e17e commit 40979e1

10 files changed

Lines changed: 735 additions & 91 deletions

File tree

agent/skills/dashboard-design/SKILL.md

Lines changed: 144 additions & 27 deletions
Large diffs are not rendered by default.

backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,17 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) {
140140
} catch (IOException io) {
141141
throw new ClientGoneException(io);
142142
}
143+
},
144+
(kind, id, html) -> {
145+
try {
146+
Map<String, Object> data = new java.util.HashMap<>();
147+
data.put("kind", kind);
148+
data.put("id", id);
149+
data.put("html", html);
150+
emitter.send(SseEmitter.event().name("chunk").data(data));
151+
} catch (IOException io) {
152+
throw new ClientGoneException(io);
153+
}
143154
});
144155
// Chat-only replies (greetings / tool questions) must not share the
145156
// `done` event with a real artifact — the FE's done handler always

backend/src/main/java/com/dbaagent/service/AgentChatClient.java

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import java.util.ArrayList;
2323
import java.util.List;
2424
import java.util.Map;
25+
import java.util.regex.Matcher;
26+
import java.util.regex.Pattern;
2527

2628
/**
2729
* Synchronous client for the DeepSQL Agent webui API, reached over the compose
@@ -144,6 +146,26 @@ private void switchProfile(String profile) throws Exception {
144146
* assembled assistant text and the tool steps it ran.
145147
*/
146148
public AgentReply sendAndAwait(String sessionId, String message) {
149+
return sendAndAwait(sessionId, message, null);
150+
}
151+
152+
/** Live per-tool-call notifications ("SQL · select …", "skill · …") as they happen. */
153+
public interface ToolStepListener {
154+
void onToolStep(String label);
155+
}
156+
157+
/** A completed dashboard-shell or dashboard-widget fenced block, as soon as it closes. */
158+
public record ArtifactChunk(String kind, String id, String html) { }
159+
160+
public interface ArtifactChunkListener {
161+
void onChunk(ArtifactChunk chunk);
162+
}
163+
164+
public AgentReply sendAndAwait(String sessionId, String message, ToolStepListener toolSteps) {
165+
return sendAndAwait(sessionId, message, toolSteps, null);
166+
}
167+
168+
public AgentReply sendAndAwait(String sessionId, String message, ToolStepListener toolSteps, ArtifactChunkListener chunks) {
147169
String streamId;
148170
try {
149171
JsonNode started = postJson("/api/chat/start", Map.of(
@@ -156,10 +178,10 @@ public AgentReply sendAndAwait(String sessionId, String message) {
156178
} catch (Exception e) {
157179
return AgentReply.fail("could not start agent turn: " + describe(e));
158180
}
159-
return consumeStream(streamId);
181+
return consumeStream(streamId, toolSteps, chunks);
160182
}
161183

162-
private AgentReply consumeStream(String streamId) {
184+
private AgentReply consumeStream(String streamId, ToolStepListener toolStepListener, ArtifactChunkListener chunkListener) {
163185
String url = webuiUrl + "/api/chat/stream?stream_id=" + URLEncoder.encode(streamId, StandardCharsets.UTF_8);
164186
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
165187
.header("Accept", "text/event-stream")
@@ -171,6 +193,13 @@ private AgentReply consumeStream(String streamId) {
171193
List<String> toolSteps = new ArrayList<>();
172194
boolean ended = false;
173195
String streamError = null;
196+
// Scan position into `answer` for chunk detection — advances past each
197+
// extracted dashboard-shell/dashboard-widget block so a closed fence is
198+
// never re-matched, and resets with the buffer on `tool` (a chunk only
199+
// ever appears in the FINAL answer, after the last tool call, per the
200+
// skill's "no tool calls after it" contract — so a reset never splits one).
201+
int[] chunkScanPos = { 0 };
202+
int[] sqlStepCount = { 0 };
174203
try {
175204
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
176205
if (resp.statusCode() / 100 != 2) {
@@ -186,15 +215,25 @@ private AgentReply consumeStream(String streamId) {
186215
String data = line.substring(5).trim();
187216
if (event == null) continue;
188217
switch (event) {
189-
case "token" -> answer.append(textField(data, "text"));
218+
case "token" -> {
219+
answer.append(textField(data, "text"));
220+
if (chunkListener != null) chunkScanPos[0] = scanForChunks(answer, chunkScanPos[0], chunkListener);
221+
}
190222
case "tool" -> {
191223
String s = toolStep(data);
192-
if (s != null) toolSteps.add(s);
224+
if (s != null) {
225+
toolSteps.add(s);
226+
if (toolStepListener != null) {
227+
String natural = naturalLanguageStep(data, sqlStepCount);
228+
if (natural != null) toolStepListener.onToolStep(natural);
229+
}
230+
}
193231
// Everything streamed before a tool call is interim
194232
// reasoning ("I'm pulling the schema…"). Channel users
195233
// only want the final answer, so drop it — the text
196234
// after the LAST tool is the real reply.
197235
answer.setLength(0);
236+
chunkScanPos[0] = 0;
198237
}
199238
case "error", "apperror" -> {
200239
String err = textField(data, "message");
@@ -274,6 +313,30 @@ private String text(JsonNode node) {
274313
return (node == null || node.isMissingNode() || node.isNull()) ? null : node.asText(null);
275314
}
276315

316+
// Matches a CLOSED fence only — requires the trailing ``` on its own line, so a
317+
// fence still being streamed (no closing marker yet) never matches and is left
318+
// for the next token to complete. Group 2 (id="...") is present only on a widget
319+
// block. DOTALL so the body can span many token-appended lines.
320+
private static final Pattern CHUNK_FENCE = Pattern.compile(
321+
"```dashboard-(shell|widget)(?:\\s+id=\"([^\"]+)\")?\\s*\\n(.*?)\\n```",
322+
Pattern.DOTALL);
323+
324+
/** Scans answer[fromPos..] for complete chunks, firing the listener for each. Returns the new scan position. */
325+
private int scanForChunks(StringBuilder answer, int fromPos, ArtifactChunkListener listener) {
326+
Matcher m = CHUNK_FENCE.matcher(answer).region(fromPos, answer.length());
327+
int pos = fromPos;
328+
while (m.find()) {
329+
String kind = m.group(1);
330+
String id = m.group(2);
331+
String html = m.group(3);
332+
try {
333+
listener.onChunk(new ArtifactChunk(kind, id, html));
334+
} catch (Exception ignored) { }
335+
pos = m.end();
336+
}
337+
return pos;
338+
}
339+
277340
private String textField(String data, String field) {
278341
try {
279342
return objectMapper.readTree(data).path(field).asText("");
@@ -297,4 +360,38 @@ private String toolStep(String data) {
297360
return null;
298361
}
299362
}
363+
364+
// Present-progressive phrase per tool, for a user-facing live trace — never the
365+
// raw tool name, query text, or a table/column name (the dashboard artifact
366+
// itself is held to the same no-internals-visible bar; the trace shouldn't leak
367+
// what the artifact is required to hide).
368+
private static final String[] SQL_STEP_PHRASES = {
369+
"Checking the numbers…", "Double-checking the data…", "Verifying a query against your data…",
370+
"Confirming the figures…", "Running another check…",
371+
};
372+
373+
// sqlStepCount varies the SQL-verification phrase across calls so a multi-widget
374+
// build doesn't repeat one line 6 times — passed in per-call via int[] (a single-
375+
// element mutable box) rather than an instance field, since this @Service is a
376+
// shared singleton and an instance field would race across concurrent turns from
377+
// different users/dashboards.
378+
private String naturalLanguageStep(String data, int[] sqlStepCount) {
379+
try {
380+
JsonNode d = objectMapper.readTree(data);
381+
String name = d.path("name").asText("");
382+
JsonNode args = d.path("args");
383+
if (!args.path("query").asText("").isBlank()) {
384+
return SQL_STEP_PHRASES[sqlStepCount[0]++ % SQL_STEP_PHRASES.length];
385+
}
386+
if ("skill_view".equals(name)) return "Loading dashboard-building expertise…";
387+
return switch (name.replaceFirst("^mcp_deepsql_", "")) {
388+
case "get_brain_context", "list_business_rules" -> "Reviewing your business rules…";
389+
case "get_schema", "get_relationships" -> "Understanding your database structure…";
390+
case "execute_sql" -> SQL_STEP_PHRASES[sqlStepCount[0]++ % SQL_STEP_PHRASES.length];
391+
default -> null; // an unrecognized/future tool stays silent rather than leaking its raw name
392+
};
393+
} catch (Exception e) {
394+
return null;
395+
}
396+
}
300397
}

0 commit comments

Comments
 (0)