Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ their native runners. See `desktop/README.md` for the full picture.
Dashboards are **generated by the embedded DeepSQL Agent acting as a coding agent**
(customized Hermes runtime — see [`agent/README.md`](agent/README.md)) — it writes the whole dashboard as a single self-contained HTML document, not a JSON spec. The earlier spec+renderer model (metrics/charts/tables + a `{{placeholder}}` substitution engine + `DashboardBuilder.js`) was thrown away: the rigid `col BETWEEN {{name}}` convention couldn't express real SQL (e.g. a Unix-epoch date filter → `near '{range.start}'` syntax errors) and boxed the agent in.

- `DashboardAgentService` is a thin broker: `ensureProfileForUser` → `ensureSession` (fresh session) → `sendAndAwait` with an **artifact contract**. The agent grounds on the brain/schema, verifies every query with `execute_sql`, then emits ONE HTML doc (in a ` ```html ` block). The broker extracts the HTML and returns `{version:3, renderMode:"artifact", title, html, trace}`, stored verbatim in `saved_dashboards.dashboardConfig`.
- `DashboardAgentService` is a thin broker: `ensureProfileForUser` → `ensureSession` (fresh session) → `sendAndAwait` with an **artifact contract**. The agent grounds on the brain/schema, verifies every query with `execute_sql`, then emits ONE HTML doc (in a ` ```html ` block). The broker extracts the HTML and returns `{version:3, renderMode:"artifact", title, html, summary, trace}`, stored verbatim in `saved_dashboards.dashboardConfig`.
- **The chat reply is the agent's own words, not a template.** The build contract ends with a required ` ```dashboard-note ` fence (1–3 sentences): what THIS turn changed, plus anything it could not do/verify or deliberately skipped, and on a correction turn whether the disputed value actually changed. `DashboardAgentService.extractSummary` reads it (and `stripNotes` removes it *before* HTML extraction, so prose can't be mistaken for the artifact); it rides in `config.summary`. Both persistence (`SavedDashboardService.buildReplyText`) and the live tab (`useDashboardChatStore.buildReplyText`) render it, falling back to `DEFAULT_BUILD_REPLY` ("Done — built and verified…") only when a turn produced no note. Before this, the contract banned prose and both sites hardcoded that constant, so every build — including one answering a user's correction ("you're showing 3k, I expected 6k+") — reported the same confident "built and verified", which is worse than templated: it claimed success over an unverified change. **The two `buildReplyText` helpers must stay textually in sync** (backend Java + frontend JS) — the live bubble and the persisted reply are read from the same `summary`.
- The agent loads the **`dashboard-design` skill** (`agent/skills/dashboard-design/SKILL.md`, v2 — artifact contract, the `deepsql.query` runtime, composition/UX rules, an **intent checklist**, and Unix-epoch date handling).
- **Rendering + data access**: `DashboardArtifact.jsx` renders the HTML in a **sandboxed iframe** (`sandbox="allow-scripts"`, opaque origin + a strict CSP — no external network). The artifact fetches data only through an injected `deepsql.query(sql)` bridge that `postMessage`s to the parent; the parent calls **`POST /api/dashboards/query`** (`DashboardQueryController`), which is **read-only twice over** (`McpSqlGuardService.validateReadOnlySql` + `QueryExecutionContext.api` = `READ_ONLY_ONLY`) and access-scoped via `assertCanReadConnectionContent`. So the agent's code has full creative freedom while every query stays guarded and sandboxed. The bridge also auto-sizes the iframe and forwards runtime errors.
- Generation endpoints unchanged (`POST /api/dashboards/generate` + `/generate/stream`). `DashboardBuilder.js`/`DashboardInputs.js` remain only because `tabs/Core/PreviewTab.js` still uses them — the dashboard *creation* path no longer touches them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@
* 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.
*
* <p>A final {@code dashboard-note} fence carries the agent's own account of the
* turn — what changed, and what it could not do or verify — which becomes the
* chat reply. Before this, the contract banned prose and the UI showed a
* hardcoded "Done — built and verified against your data" on every build, so a
* turn that ignored the user's correction still reported success. The note is
* stripped before HTML extraction and falls back to that constant if absent.
*
* <p>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.
Expand Down Expand Up @@ -146,7 +153,8 @@ sessionId, buildTask(connectionId, prompt, currentConfig),
"The agent couldn't build the dashboard: " + (reply.error() == null ? "it ended early" : reply.error()));
}

String html = extractHtml(reply.text());
String summary = extractSummary(reply.text());
String html = extractHtml(stripNotes(reply.text()));
if (html == null || html.isBlank()) {
log.warn("Dashboard agent returned no HTML artifact. Reply head: {}",
reply.text() == null ? "null" : reply.text().substring(0, Math.min(300, reply.text().length())));
Expand All @@ -165,6 +173,7 @@ sessionId, buildTask(connectionId, prompt, currentConfig),
cfg.put("renderMode", "artifact");
cfg.put("title", title);
cfg.put("html", html);
if (summary != null) cfg.put("summary", summary);
cfg.put("trace", trace);
return cfg;
}
Expand Down Expand Up @@ -321,9 +330,22 @@ database jargon anywhere visible (titles, labels, descriptions, captions, errors
--ds-shadow). Do NOT import fonts or set font-family. Stay monochrome; use a subtle soft color/gradient
ONLY to highlight the 1–2 most important KPIs. Clean, minimal, lots of whitespace — not dark or neon.

Output your FINAL message as ONLY the fenced blocks described in step 5 above (one ```dashboard-shell```
first, then one ```dashboard-widget id="..."``` per widget) — no prose, no tool calls after the last one.
Do NOT wrap the whole thing in a single ```html block — the shell and each widget are SEPARATE fences.""");
Output your FINAL message as ONLY fenced blocks (one ```dashboard-shell``` first, then one
```dashboard-widget id="..."``` per widget, then the ```dashboard-note``` below) — no loose prose
outside a fence, no tool calls after the last one.
Do NOT wrap the whole thing in a single ```html block — the shell and each widget are SEPARATE fences.

7. END with a ```dashboard-note``` fence: 1-3 sentences to the person who asked, in the same plain
business language as the two hard rules above (a note naming a table, a column, SQL, or the
connection id breaks the same security requirement the dashboard itself is bound by).
Say what THIS turn actually changed — not that a dashboard exists. Then, in the same note:
- State anything you could NOT do, could not verify, or chose to skip, and why. A build that
partly worked must say so. Never claim a number is correct because a query returned it.
- If the user was correcting or disputing something (a wrong figure, a chart that didn't load),
say plainly whether it is now fixed, and what the value/behaviour is now versus what they
reported. If you could not reproduce or resolve their complaint, say THAT — do not answer a
correction with a description of what you built.
Write it as you would to a colleague: specific and short. Never open with "Done".""");
return sb.toString();
}

Expand All @@ -337,6 +359,28 @@ database jargon anywhere visible (titles, labels, descriptions, captions, errors
"```dashboard-(shell|widget)(?:\\s+id=\"([^\"]+)\")?\\s*\\n(.*?)\\n```",
Pattern.DOTALL);

// The agent's own account of the turn. Stripped before HTML extraction runs:
// prose mentioning a tag would otherwise be a candidate for extractHtml's
// looks-like-markup fallback.
private static final Pattern NOTE_FENCE = Pattern.compile(
"```dashboard-note\\s*\\n(.*?)\\n```", Pattern.DOTALL);

private static final int MAX_SUMMARY_CHARS = 600;

/** The agent's summary of this turn, or null if it emitted no note. */
private String extractSummary(String text) {
if (text == null || text.isBlank()) return null;
Matcher m = NOTE_FENCE.matcher(text);
String note = null;
while (m.find()) note = m.group(1).trim();
if (note == null || note.isBlank()) return null;
return trim(note, MAX_SUMMARY_CHARS);
}

private String stripNotes(String text) {
return text == null ? null : NOTE_FENCE.matcher(text).replaceAll("");
}

/**
* Assembles the final document from the agent's shell + widget chunks (the
* progressive-render contract), substituting each widget's HTML+script into
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public class SavedDashboardService {
// unbounded snapshot table. Oldest beyond this count are pruned on each write.
private static final int MAX_VERSIONS_PER_DASHBOARD = 50;

static final String DEFAULT_BUILD_REPLY =
"Done — built and verified against your data. Saved as a draft — tell me what to change.";

@Autowired
private SavedDashboardRepository savedDashboardRepository;

Expand Down Expand Up @@ -409,11 +412,21 @@ public SavedDashboard completeBuildTurn(UUID dashboardId, Map<String, Object> co
dashboard.setName(String.valueOf(title));
}
List<Map<String, Object>> messages = parseMessages(dashboard.getChatMessages());
messages.add(chatMessage("agent", "Done — built and verified against your data. Saved as a draft — tell me what to change."));
messages.add(chatMessage("agent", buildReplyText(config)));
dashboard.setChatMessages(writeMessages(messages));
return finishRunning(dashboard);
}

// The agent's own dashboard-note. Falls back to the old constant only when a
// turn produced no note, so an agent still on the previous contract works.
private String buildReplyText(Map<String, Object> config) {
Object summary = config == null ? null : config.get("summary");
if (summary != null && !String.valueOf(summary).isBlank()) {
return String.valueOf(summary).trim();
}
return DEFAULT_BUILD_REPLY;
}

/** Turn finished as a real generation failure (not a client disconnect — see controller). */
@Transactional
public SavedDashboard appendErrorReply(UUID dashboardId, String errorText) {
Expand Down
12 changes: 11 additions & 1 deletion src/lib/stores/useDashboardChatStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import { useShallow } from 'zustand/react/shallow'
import { generateDashboardStream } from '@/lib/dashboardGenerator'
import { savedDashboardsAPI } from '@/lib/api/client'

// Mirrors SavedDashboardService.buildReplyText — this tab renders the reply
// live, the backend persists its own copy, and the two must read the same.
const DEFAULT_BUILD_REPLY =
'Done — built and verified against your data. Saved as a draft — tell me what to change.'

const buildReplyText = (config) => {
const summary = config?.summary
return typeof summary === 'string' && summary.trim() ? summary.trim() : DEFAULT_BUILD_REPLY
}

// Keeps each dashboard workspace's in-flight generation (chat messages,
// streaming steps, the built config, the abort fn) alive in memory across
// component mount/unmount. DashboardWorkspace used to hold all of this in
Expand Down Expand Up @@ -282,7 +292,7 @@ export const useDashboardChatStore = create((set, get) => ({
liveShell: null,
liveWidget: null,
liveWidgets: null,
messages: [...cur.messages, { role: 'agent', text: 'Done — built and verified against your data. Saved as a draft — tell me what to change.' }],
messages: [...cur.messages, { role: 'agent', text: buildReplyText(next) }],
}
})
// completeBuildTurn (the backend's own persistence for this turn) just
Expand Down
Loading