feat: chart support — make_chart tool with recharts rendering
Adds inline data visualisation to the chat agent. After a successful
run_query, the agent can call make_chart(chart_type, x, y, [group,
title, orientation]) and the result is rendered as a bar / line / area
/ pie chart inline in the chat thread, sourced from the previous query
result.
Backend (commands/chat.rs, models/chat.rs)
- New ChartConfig{chart_type, x, y, group?, title?, orientation?} model.
- New AgentAction::MakeChart{config} variant. Parser accepts both
`chart_type` and the alternative `type` field name (qwen3 sometimes
emits the latter). Validates chart_type is one of bar/line/area/pie.
- last_successful_query_result helper finds the most recent successful
run_query in the working thread.
- MakeChart dispatcher: validates that x/y/group columns exist in the
attached query result, emits a tool_result with the same QueryResult
in `result` and the chart_config JSON in `text`. Mismatches surface
as a clear error ("y column `name` is not in the last result.
Available: company_name, legal_name, …").
- build_history compression unchanged: make_chart's tool_result text
field (the small chart_config JSON) is included in LLM history; the
large QueryResult.rows are NOT, since the per-tool branch only emits
text for non-run_query tools.
- System prompt: documents make_chart with concrete usage hints
(top-N → bar, time series → line/area, proportions → pie; skip for
≤2 or >500 rows). 7 new parser/dispatcher tests.
Frontend (src/components/chat/)
- recharts ^3.8 added.
- New ChartPreview component renders bar (vertical+horizontal), line,
area, pie. Supports grouped series via the `group` config field by
pivoting rows into a wide format. Y values coerced to numbers
(parses strings, nulls → 0). Caps to 500 points to keep things
responsive on huge results.
- ChatMessageView routes tool=="make_chart" tool_result through a new
ChartToolResult that parses the config JSON from the message text
and feeds the embedded QueryResult into ChartPreview.
- New labels/icons (BarChart3) and preview-extraction for make_chart
in tool-call collapsed headers (`bar: carrier_name → trip_count`).
Verification: cargo test --lib 77 pass (+7), tsc clean, vitest 20
pass.
This commit is contained in:
@@ -7,7 +7,7 @@ use crate::commands::memory::{append_memory_core, read_memory_core};
|
||||
use crate::commands::queries::execute_query_core;
|
||||
use crate::error::{TuskError, TuskResult};
|
||||
use crate::models::ai::OllamaChatMessage;
|
||||
use crate::models::chat::{ChatMessage, ChatTurnResult, ContextUsage};
|
||||
use crate::models::chat::{ChartConfig, ChatMessage, ChatTurnResult, ContextUsage};
|
||||
use crate::models::query_result::QueryResult;
|
||||
use crate::state::AppState;
|
||||
use chrono::Utc;
|
||||
@@ -50,6 +50,7 @@ enum AgentAction {
|
||||
Remember { note: String },
|
||||
SaveQuery { name: String, sql: String },
|
||||
FindQueries { text: String },
|
||||
MakeChart { config: ChartConfig },
|
||||
}
|
||||
|
||||
/// Parse the model's JSON response. Accepts both shapes the model tends to emit:
|
||||
@@ -151,6 +152,55 @@ fn parse_agent_action(raw: &str) -> Result<AgentAction, String> {
|
||||
}
|
||||
Ok(AgentAction::FindQueries { text })
|
||||
}
|
||||
"make_chart" => {
|
||||
let chart_type = lookup("chart_type")
|
||||
.or_else(|| lookup("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "make_chart missing `chart_type`".to_string())?
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if !["bar", "line", "area", "pie"].contains(&chart_type.as_str()) {
|
||||
return Err(format!(
|
||||
"make_chart `chart_type` must be one of: bar, line, area, pie. Got: {}",
|
||||
chart_type
|
||||
));
|
||||
}
|
||||
let x = lookup("x")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "make_chart missing `x` column".to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
let y = lookup("y")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "make_chart missing `y` column".to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
if x.is_empty() || y.is_empty() {
|
||||
return Err("make_chart `x` and `y` must not be empty".into());
|
||||
}
|
||||
let group = lookup("group")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let title = lookup("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let orientation = lookup("orientation")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty());
|
||||
Ok(AgentAction::MakeChart {
|
||||
config: ChartConfig {
|
||||
chart_type,
|
||||
x,
|
||||
y,
|
||||
group,
|
||||
title,
|
||||
orientation,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Legacy from earlier iterations — silently ignored at parse time so the
|
||||
// model can recover with a different action.
|
||||
"get_schema" => Err(
|
||||
@@ -230,6 +280,9 @@ You operate as an agent in a single-tool-per-turn loop with hop limit {hops}. On
|
||||
{{"action":"save_query","name":"<short label>","sql":"<the SQL>"}}
|
||||
Persist a non-trivial working SELECT for reuse later. Use AFTER a successful run_query when the query is likely to be re-run. Keep `name` short and descriptive (e.g. "GMV by carrier — last 30d"). The user sees these in sidebar → Saved.
|
||||
|
||||
{{"action":"make_chart","chart_type":"bar","x":"<col>","y":"<col>","title":"<short title>"}}
|
||||
Visualise the LAST successful run_query result as a chart inline. `chart_type` is one of: bar, line, area, pie. `x` and `y` MUST be column names from the previous result. Optional: `group` (column for series), `orientation` ("vertical"/"horizontal", bar only). Use after run_query when the data is aggregated and would be clearer as a chart (top-N comparisons → bar; time series → line/area; proportions → pie). Skip for tiny results (≤2 rows) and giant ones (>500 rows).
|
||||
|
||||
{{"action":"final","text":"..."}}
|
||||
End the turn with a plain-language answer for the user. Do NOT repeat the result table — the UI shows it. Mention caveats (LIMIT, NULL filters, sampling).
|
||||
|
||||
@@ -671,6 +724,92 @@ pub async fn chat_send(
|
||||
);
|
||||
push_tool_result(&mut new_messages, &mut working, result);
|
||||
}
|
||||
AgentAction::MakeChart { config } => {
|
||||
let config_json = serde_json::to_string(&config).unwrap_or_else(|_| "{}".into());
|
||||
push_tool_call(
|
||||
&mut new_messages,
|
||||
&mut working,
|
||||
"make_chart",
|
||||
config_json.clone(),
|
||||
);
|
||||
|
||||
let result_msg = match last_successful_query_result(&working) {
|
||||
None => ChatMessage::ToolResult {
|
||||
id: new_id("res"),
|
||||
tool: "make_chart".to_string(),
|
||||
is_error: true,
|
||||
text: Some(
|
||||
"make_chart needs a successful run_query result above it. Run a SELECT first, then call make_chart."
|
||||
.to_string(),
|
||||
),
|
||||
result: None,
|
||||
created_at: now_ms(),
|
||||
},
|
||||
Some(qr) => {
|
||||
if !qr.columns.iter().any(|c| c == &config.x) {
|
||||
ChatMessage::ToolResult {
|
||||
id: new_id("res"),
|
||||
tool: "make_chart".to_string(),
|
||||
is_error: true,
|
||||
text: Some(format!(
|
||||
"x column `{}` is not in the last result. Available: {}.",
|
||||
config.x,
|
||||
qr.columns.join(", ")
|
||||
)),
|
||||
result: None,
|
||||
created_at: now_ms(),
|
||||
}
|
||||
} else if !qr.columns.iter().any(|c| c == &config.y) {
|
||||
ChatMessage::ToolResult {
|
||||
id: new_id("res"),
|
||||
tool: "make_chart".to_string(),
|
||||
is_error: true,
|
||||
text: Some(format!(
|
||||
"y column `{}` is not in the last result. Available: {}.",
|
||||
config.y,
|
||||
qr.columns.join(", ")
|
||||
)),
|
||||
result: None,
|
||||
created_at: now_ms(),
|
||||
}
|
||||
} else if let Some(group) = &config.group {
|
||||
if !qr.columns.iter().any(|c| c == group) {
|
||||
ChatMessage::ToolResult {
|
||||
id: new_id("res"),
|
||||
tool: "make_chart".to_string(),
|
||||
is_error: true,
|
||||
text: Some(format!(
|
||||
"group column `{}` is not in the last result. Available: {}.",
|
||||
group,
|
||||
qr.columns.join(", ")
|
||||
)),
|
||||
result: None,
|
||||
created_at: now_ms(),
|
||||
}
|
||||
} else {
|
||||
ChatMessage::ToolResult {
|
||||
id: new_id("res"),
|
||||
tool: "make_chart".to_string(),
|
||||
is_error: false,
|
||||
text: Some(config_json.clone()),
|
||||
result: Some(qr),
|
||||
created_at: now_ms(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatMessage::ToolResult {
|
||||
id: new_id("res"),
|
||||
tool: "make_chart".to_string(),
|
||||
is_error: false,
|
||||
text: Some(config_json.clone()),
|
||||
result: Some(qr),
|
||||
created_at: now_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
push_tool_result(&mut new_messages, &mut working, result_msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Any non-RunQuery, non-Final action means the model is investigating
|
||||
@@ -825,6 +964,26 @@ fn format_db_error(e: &TuskError) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
/// Locate the most recent SUCCESSFUL run_query in the working thread and
|
||||
/// return its full QueryResult. Used by make_chart to attach data to a chart
|
||||
/// directive without relying on the model to re-send it.
|
||||
fn last_successful_query_result(messages: &[ChatMessage]) -> Option<QueryResult> {
|
||||
for m in messages.iter().rev() {
|
||||
if let ChatMessage::ToolResult {
|
||||
tool,
|
||||
is_error: false,
|
||||
result: Some(qr),
|
||||
..
|
||||
} = m
|
||||
{
|
||||
if tool == "run_query" {
|
||||
return Some(qr.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Pull the most recent run_query error text from the working thread, so the
|
||||
/// post-loop "I gave up" summary can quote concrete errors back to the user.
|
||||
fn last_run_query_error(messages: &[ChatMessage]) -> Option<String> {
|
||||
@@ -1307,6 +1466,119 @@ mod tests {
|
||||
assert!(last_run_query_error(&msgs).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_make_chart_minimal() {
|
||||
let a = parse_agent_action(
|
||||
r#"{"action":"make_chart","chart_type":"bar","x":"carrier","y":"trips"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
match a {
|
||||
AgentAction::MakeChart { config } => {
|
||||
assert_eq!(config.chart_type, "bar");
|
||||
assert_eq!(config.x, "carrier");
|
||||
assert_eq!(config.y, "trips");
|
||||
assert!(config.group.is_none());
|
||||
assert!(config.title.is_none());
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_make_chart_with_group_and_title() {
|
||||
let a = parse_agent_action(
|
||||
r#"{"action":"make_chart","chart_type":"line","x":"month","y":"revenue","group":"region","title":"Revenue"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
match a {
|
||||
AgentAction::MakeChart { config } => {
|
||||
assert_eq!(config.group.as_deref(), Some("region"));
|
||||
assert_eq!(config.title.as_deref(), Some("Revenue"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_chart_accepts_alternative_field_name_type() {
|
||||
// Some models emit `type` instead of `chart_type`.
|
||||
let a = parse_agent_action(
|
||||
r#"{"action":"make_chart","type":"pie","x":"label","y":"value"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
match a {
|
||||
AgentAction::MakeChart { config } => assert_eq!(config.chart_type, "pie"),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_make_chart_with_unknown_chart_type() {
|
||||
let r = parse_agent_action(
|
||||
r#"{"action":"make_chart","chart_type":"radar","x":"a","y":"b"}"#,
|
||||
);
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_make_chart_missing_x_or_y() {
|
||||
assert!(parse_agent_action(r#"{"action":"make_chart","chart_type":"bar","y":"a"}"#).is_err());
|
||||
assert!(parse_agent_action(r#"{"action":"make_chart","chart_type":"bar","x":"a"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_successful_query_result_finds_recent() {
|
||||
use crate::models::query_result::QueryResult;
|
||||
let qr = QueryResult {
|
||||
columns: vec!["a".into()],
|
||||
types: vec!["INT4".into()],
|
||||
rows: vec![vec![Value::Number(1.into())]],
|
||||
row_count: 1,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
let msgs = vec![
|
||||
ChatMessage::ToolResult {
|
||||
id: "r1".into(),
|
||||
tool: "run_query".into(),
|
||||
is_error: false,
|
||||
text: None,
|
||||
result: Some(qr.clone()),
|
||||
created_at: 1,
|
||||
},
|
||||
ChatMessage::ToolResult {
|
||||
id: "r2".into(),
|
||||
tool: "run_query".into(),
|
||||
is_error: true,
|
||||
text: Some("oops".into()),
|
||||
result: None,
|
||||
created_at: 2,
|
||||
},
|
||||
];
|
||||
let found = last_successful_query_result(&msgs).expect("ok");
|
||||
assert_eq!(found.columns, vec!["a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_successful_query_result_skips_non_run_query() {
|
||||
use crate::models::query_result::QueryResult;
|
||||
let qr = QueryResult {
|
||||
columns: vec!["a".into()],
|
||||
types: vec!["INT4".into()],
|
||||
rows: vec![],
|
||||
row_count: 0,
|
||||
execution_time_ms: 0,
|
||||
};
|
||||
let msgs = vec![ChatMessage::ToolResult {
|
||||
id: "r1".into(),
|
||||
tool: "list_tables".into(),
|
||||
is_error: false,
|
||||
text: Some("public.x".into()),
|
||||
result: Some(qr),
|
||||
created_at: 1,
|
||||
}];
|
||||
assert!(last_successful_query_result(&msgs).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_thread_for_summary_includes_roles_and_skips_rows() {
|
||||
let msgs = vec![
|
||||
|
||||
@@ -32,3 +32,17 @@ pub struct ChatTurnResult {
|
||||
pub usage: ContextUsage,
|
||||
}
|
||||
|
||||
/// Chart configuration produced by the agent's `make_chart` tool.
|
||||
/// Embedded as JSON in `ToolResult.text` for tool == "make_chart" while the
|
||||
/// underlying data lives in `ToolResult.result`. The frontend reads both to
|
||||
/// render the chart inline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChartConfig {
|
||||
pub chart_type: String, // "bar" | "line" | "area" | "pie"
|
||||
pub x: String, // column name for X axis / category
|
||||
pub y: String, // column name for Y axis / numeric value
|
||||
pub group: Option<String>, // optional column for series grouping
|
||||
pub title: Option<String>,
|
||||
pub orientation: Option<String>, // "vertical" | "horizontal" — bar only
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user