Implement persistent native A2UI chat surfaces

This commit is contained in:
2026-07-19 16:57:43 +02:00
parent ac611e3f7f
commit fd4fd744e0
25 changed files with 2571 additions and 18 deletions

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE chat_conversations DROP COLUMN surface_state;

View File

@@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE chat_conversations ADD COLUMN surface_state TEXT;

View File

@@ -36,7 +36,7 @@ mod tests {
let applied = db
.conn()
.with_migrations(|conn| conn.applied_migrations().unwrap().len());
assert_eq!(applied, 5);
assert_eq!(applied, 6);
}
#[test]
@@ -204,4 +204,40 @@ mod tests {
.unwrap();
assert_eq!(usage, (Some(8), Some(5), None, None));
}
#[test]
fn existing_conversations_are_preserved_when_surface_state_is_added() {
let db = Database::open_in_memory().unwrap();
db.conn().with_migrations(|conn| {
for _ in 0..5 {
conn.run_next_migration(MIGRATIONS).unwrap();
}
});
db.conn()
.with(|conn| {
diesel::insert_into(chat_conversations::table)
.values((
chat_conversations::id.eq("existing-surface-chat"),
chat_conversations::title.eq("Keep me"),
chat_conversations::created_at.eq(1_i64),
chat_conversations::updated_at.eq(1_i64),
))
.execute(conn)
})
.unwrap();
run_migrations(db.conn()).unwrap();
let (title, state) = db
.conn()
.with(|conn| {
chat_conversations::table
.filter(chat_conversations::id.eq("existing-surface-chat"))
.select((chat_conversations::title, chat_conversations::surface_state))
.first::<(String, Option<String>)>(conn)
})
.unwrap();
assert_eq!(title, "Keep me");
assert_eq!(state, None);
}
}

View File

@@ -92,6 +92,22 @@ pub fn set_session_id(
})
}
pub fn set_surface_state(
conn: &DbConnection,
id: &str,
state: &str,
updated_at: i64,
) -> QueryResult<usize> {
conn.with(|connection| {
diesel::update(chat_conversations::table.find(id))
.set((
chat_conversations::surface_state.eq(state),
chat_conversations::updated_at.eq(updated_at),
))
.execute(connection)
})
}
pub fn delete_conversation(conn: &DbConnection, id: &str) -> QueryResult<usize> {
conn.with(|connection| {
connection.transaction(|connection| {

View File

@@ -64,6 +64,7 @@ diesel::table! {
title -> Text,
model -> Nullable<Text>,
copilot_session_id -> Nullable<Text>,
surface_state -> Nullable<Text>,
created_at -> BigInt,
updated_at -> BigInt,
}

View File

@@ -52,6 +52,8 @@ pub enum ChatEvent {
ToolStarted {
conversation_id: String,
name: String,
surface_id: String,
arguments: Value,
},
ToolFinished {
conversation_id: String,
@@ -279,6 +281,7 @@ pub fn create_conversation_titled(
title,
model,
copilot_session_id: None,
surface_state: None,
created_at: now,
updated_at: now,
},
@@ -373,6 +376,38 @@ pub fn list_messages(conn: &Connection, id: &str) -> EngineResult<Vec<ChatMessag
Ok(queries::list_messages(conn, id)?)
}
pub fn get_surface_state(
conn: &Connection,
conversation_id: &str,
) -> EngineResult<crate::engine::chat_surfaces::ChatSurfaceState> {
let conversation = match queries::get_conversation(conn, conversation_id) {
Ok(conversation) => conversation,
Err(diesel::result::Error::NotFound) => return Ok(Default::default()),
Err(error) => return Err(error.into()),
};
conversation.surface_state.as_deref().map_or_else(
|| Ok(Default::default()),
|state| {
serde_json::from_str(state)
.map_err(|error| EngineError::Parse(format!("invalid chat surface state: {error}")))
},
)
}
pub fn put_surface_state(
conn: &Connection,
conversation_id: &str,
state: &crate::engine::chat_surfaces::ChatSurfaceState,
) -> EngineResult<()> {
let state = serde_json::to_string(state)?;
if queries::set_surface_state(conn, conversation_id, &state, now_unix_ms())? == 0 {
return Err(EngineError::NotFound(format!(
"chat conversation {conversation_id}"
)));
}
Ok(())
}
pub fn insert_message(
conn: &Connection,
conversation_id: &str,
@@ -633,7 +668,7 @@ fn run_turns(
let serialized_calls = (!response.tool_calls.is_empty())
.then(|| serialize_tool_calls(&response.tool_calls))
.transpose()?;
insert_message(
let assistant_message = insert_message(
conn,
conversation_id,
ChatRole::Assistant,
@@ -667,6 +702,8 @@ fn run_turns(
ChatEvent::ToolStarted {
conversation_id: conversation_id.to_string(),
name: call.name.clone(),
surface_id: format!("{}-surface-{index}", assistant_message.id),
arguments: call.arguments.clone(),
},
);
if cancelled.load(Ordering::SeqCst) {

View File

@@ -0,0 +1,583 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::model::ChatMessage;
pub const RENDER_TOOL_NAMES: [&str; 8] = [
"render_card",
"render_chart",
"render_form",
"render_list",
"render_metric",
"render_mindmap",
"render_table",
"render_tabs",
];
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ChatSurfaceState {
#[serde(default)]
pub surface_data: BTreeMap<String, BTreeMap<String, Value>>,
#[serde(default)]
pub surface_tabs: BTreeMap<String, usize>,
#[serde(default)]
pub dismissed_surfaces: BTreeSet<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SurfaceKind {
Card,
Chart,
Form,
List,
Metric,
Mindmap,
Table,
Tabs,
Text,
Json,
}
impl SurfaceKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Card => "card",
Self::Chart => "chart",
Self::Form => "form",
Self::List => "list",
Self::Metric => "metric",
Self::Mindmap => "mindmap",
Self::Table => "table",
Self::Tabs => "tabs",
Self::Text => "text",
Self::Json => "json",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChartType {
Bar,
StackedBar,
Line,
Area,
Pie,
Donut,
Heatmap,
}
impl ChartType {
pub const fn as_str(self) -> &'static str {
match self {
Self::Bar => "bar",
Self::StackedBar => "stacked-bar",
Self::Line => "line",
Self::Area => "area",
Self::Pie => "pie",
Self::Donut => "donut",
Self::Heatmap => "heatmap",
}
}
fn parse(value: Option<&str>) -> Self {
match value {
Some("stacked-bar") => Self::StackedBar,
Some("line") => Self::Line,
Some("area") => Self::Area,
Some("pie") => Self::Pie,
Some("donut") => Self::Donut,
Some("heatmap") => Self::Heatmap,
_ => Self::Bar,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormInputType {
Text,
Textarea,
Select,
Checkbox,
Date,
Number,
}
impl FormInputType {
fn parse(value: Option<&str>) -> Self {
match value {
Some("textarea") => Self::Textarea,
Some("select") => Self::Select,
Some("checkbox") => Self::Checkbox,
Some("date") => Self::Date,
Some("number") => Self::Number,
_ => Self::Text,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct InlineSurface {
pub id: String,
pub kind: SurfaceKind,
pub title: Option<String>,
pub subtitle: Option<String>,
pub body: Option<String>,
pub actions: Vec<SurfaceAction>,
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
pub chart_type: Option<ChartType>,
pub series: Vec<ChartSeries>,
pub max_value: Option<f64>,
pub label: Option<String>,
pub value: Option<String>,
pub items: Vec<String>,
pub nodes: Vec<MindmapNode>,
pub fields: Vec<FormField>,
pub submit_label: Option<String>,
pub submit_action: Option<String>,
pub tabs: Vec<TabPanel>,
pub selected_index: Option<usize>,
pub raw: Option<Value>,
}
impl InlineSurface {
fn empty(id: String, kind: SurfaceKind) -> Self {
Self {
id,
kind,
title: None,
subtitle: None,
body: None,
actions: vec![],
columns: vec![],
rows: vec![],
chart_type: None,
series: vec![],
max_value: None,
label: None,
value: None,
items: vec![],
nodes: vec![],
fields: vec![],
submit_label: None,
submit_action: None,
tabs: vec![],
selected_index: None,
raw: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SurfaceAction {
pub label: String,
pub action: String,
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChartSeries {
pub label: String,
pub value: f64,
pub segments: Vec<ChartSegment>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChartSegment {
pub label: String,
pub value: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MindmapNode {
pub id: Option<String>,
pub label: String,
pub children: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormField {
pub key: String,
pub label: String,
pub input_type: FormInputType,
pub placeholder: Option<String>,
pub value: Value,
pub options: Vec<FieldOption>,
pub required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldOption {
pub label: String,
pub value: String,
}
impl std::fmt::Display for FieldOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TabPanel {
pub label: String,
pub content: Vec<InlineSurface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SurfaceNavigation {
pub destination: String,
pub entity_id: Option<String>,
}
pub fn build_message_surfaces(
message: &ChatMessage,
state: &ChatSurfaceState,
) -> Vec<InlineSurface> {
let Some(raw) = message.tool_calls.as_deref() else {
return vec![];
};
let Ok(calls) = serde_json::from_str::<Vec<Value>>(raw) else {
return vec![];
};
calls
.iter()
.enumerate()
.filter_map(|(index, call)| {
let function = call.get("function").unwrap_or(call);
let name = function.get("name")?.as_str()?;
let raw = function
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let arguments = raw
.as_str()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(raw);
build_render_surface(
name,
&arguments,
format!("{}-surface-{index}", message.id),
state,
)
})
.filter(|surface| !state.dismissed_surfaces.contains(&surface.id))
.collect()
}
pub fn build_render_surface(
name: &str,
arguments: &Value,
id: String,
state: &ChatSurfaceState,
) -> Option<InlineSurface> {
match name {
"render_card" => Some(card(arguments, id)),
"render_chart" => Some(chart(arguments, id)),
"render_form" => Some(form(arguments, id, state)),
"render_list" => Some(list(arguments, id)),
"render_metric" => Some(metric(arguments, id)),
"render_mindmap" => Some(mindmap(arguments, id)),
"render_table" => Some(table(arguments, id)),
"render_tabs" => Some(tabs(arguments, id, state)),
unknown if unknown.starts_with("render_") => {
let mut surface = InlineSurface::empty(id, SurfaceKind::Json);
surface.raw = Some(arguments.clone());
Some(surface)
}
_ => None,
}
}
pub fn merge_form_data(payload: Value, surface_id: &str, state: &ChatSurfaceState) -> Value {
let mut result = payload.as_object().cloned().unwrap_or_default();
if let Some(values) = state.surface_data.get(surface_id) {
result.insert(
"formData".into(),
Value::Object(values.clone().into_iter().collect()),
);
}
Value::Object(result)
}
pub fn resolve_surface_action(action: &str, payload: &Value) -> Result<SurfaceNavigation, String> {
let map = payload
.as_object()
.ok_or_else(|| "surface action payload must be an object".to_string())?;
let normalized = action.replace('_', "").to_ascii_lowercase();
let open = |destination: &str, keys: &[&str]| {
let id = keys
.iter()
.find_map(|key| map.get(*key).and_then(Value::as_str))
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| format!("surface action {action} requires an identifier"))?;
Ok(SurfaceNavigation {
destination: destination.into(),
entity_id: Some(id.into()),
})
};
match normalized.as_str() {
"openpost" => open("posts", &["postId", "post_id", "id"]),
"openmedia" => open("media", &["mediaId", "media_id", "id"]),
"openchat" => open(
"chat",
&[
"conversationId",
"conversation_id",
"chatId",
"chat_id",
"id",
],
),
"opensettings" => Ok(navigation("settings")),
"switchview" | "setview" | "setactiveview" => {
let view = map
.get("view")
.or_else(|| map.get("destination"))
.and_then(Value::as_str)
.filter(|view| {
[
"posts",
"pages",
"media",
"templates",
"scripts",
"tags",
"chat",
"import",
"git",
"settings",
]
.contains(view)
})
.ok_or_else(|| format!("surface action {action} requires a valid view"))?;
Ok(navigation(view))
}
"togglesidebar" => Ok(navigation("toggle_sidebar")),
"togglepanel" | "openpanel" => Ok(navigation("toggle_panel")),
"toggleassistantsidebar" => Ok(navigation("toggle_assistant_sidebar")),
_ => Err(format!("unsupported surface action: {action}")),
}
}
fn navigation(destination: &str) -> SurfaceNavigation {
SurfaceNavigation {
destination: destination.into(),
entity_id: None,
}
}
fn card(a: &Value, id: String) -> InlineSurface {
let mut s = InlineSurface::empty(id, SurfaceKind::Card);
s.title = string(a, "title");
s.subtitle = string(a, "subtitle");
s.body = string(a, "body");
s.actions = array(a, "actions")
.iter()
.map(|v| SurfaceAction {
label: string(v, "label").unwrap_or_default(),
action: string(v, "action").unwrap_or_default(),
payload: v.get("payload").cloned().unwrap_or_else(|| json!({})),
})
.collect();
s
}
fn chart(a: &Value, id: String) -> InlineSurface {
let mut s = InlineSurface::empty(id, SurfaceKind::Chart);
s.title = string(a, "title");
s.chart_type = Some(ChartType::parse(
a.get("chartType")
.or_else(|| a.get("chart_type"))
.and_then(Value::as_str),
));
s.series = array(a, "series")
.iter()
.map(|v| ChartSeries {
label: string(v, "label").unwrap_or_default(),
value: numeric(v.get("value")),
segments: array(v, "segments")
.iter()
.map(|seg| ChartSegment {
label: string(seg, "label").unwrap_or_default(),
value: numeric(seg.get("value")),
})
.collect(),
})
.collect();
s.max_value = Some(s.series.iter().map(|v| v.value).fold(0.0_f64, f64::max));
s
}
fn form(a: &Value, id: String, state: &ChatSurfaceState) -> InlineSurface {
let mut s = InlineSurface::empty(id.clone(), SurfaceKind::Form);
s.title = string(a, "title");
s.fields = array(a, "fields")
.iter()
.map(|v| {
let key = string(v, "key").unwrap_or_else(|| "field".into());
let default = v
.get("defaultValue")
.or_else(|| v.get("default_value"))
.cloned()
.unwrap_or(Value::Null);
FormField {
label: string(v, "label").unwrap_or_else(|| key.clone()),
input_type: FormInputType::parse(
v.get("inputType")
.or_else(|| v.get("input_type"))
.and_then(Value::as_str),
),
placeholder: string(v, "placeholder"),
value: state
.surface_data
.get(&id)
.and_then(|m| m.get(&key))
.cloned()
.unwrap_or(default),
options: array(v, "options")
.iter()
.map(|o| FieldOption {
label: string(o, "label").unwrap_or_default(),
value: string(o, "value").unwrap_or_default(),
})
.collect(),
required: v.get("required").and_then(Value::as_bool).unwrap_or(false),
key,
}
})
.collect();
s.submit_label = a
.get("submitLabel")
.or_else(|| a.get("submit_label"))
.and_then(Value::as_str)
.map(str::to_owned);
s.submit_action = a
.get("submitAction")
.or_else(|| a.get("submit_action"))
.and_then(Value::as_str)
.map(str::to_owned);
s
}
fn list(a: &Value, id: String) -> InlineSurface {
let mut s = InlineSurface::empty(id, SurfaceKind::List);
s.title = string(a, "title");
s.items = strings(a.get("items"));
s
}
fn metric(a: &Value, id: String) -> InlineSurface {
let mut s = InlineSurface::empty(id, SurfaceKind::Metric);
s.label = string(a, "label");
s.value = scalar(a.get("value"));
s
}
fn mindmap(a: &Value, id: String) -> InlineSurface {
let mut s = InlineSurface::empty(id, SurfaceKind::Mindmap);
s.title = string(a, "title");
s.nodes = array(a, "nodes")
.iter()
.map(|v| MindmapNode {
id: string(v, "id"),
label: string(v, "label").unwrap_or_default(),
children: strings(v.get("children")),
})
.collect();
s
}
fn table(a: &Value, id: String) -> InlineSurface {
let mut s = InlineSurface::empty(id, SurfaceKind::Table);
s.title = string(a, "title");
s.columns = strings(a.get("columns"));
s.rows = array(a, "rows").iter().map(|v| strings(Some(v))).collect();
s
}
fn tabs(a: &Value, id: String, state: &ChatSurfaceState) -> InlineSurface {
let mut s = InlineSurface::empty(id.clone(), SurfaceKind::Tabs);
s.title = string(a, "title");
s.tabs = array(a, "tabs")
.iter()
.enumerate()
.map(|(ti, tab)| TabPanel {
label: string(tab, "label").unwrap_or_default(),
content: array(tab, "content")
.iter()
.enumerate()
.map(|(ci, v)| nested(v, format!("{id}-tab-{ti}-{ci}"), state))
.collect(),
})
.collect();
s.selected_index = Some(
state
.surface_tabs
.get(&id)
.copied()
.unwrap_or(0)
.min(s.tabs.len().saturating_sub(1)),
);
s
}
fn nested(v: &Value, id: String, state: &ChatSurfaceState) -> InlineSurface {
let Some(object) = v.as_object() else {
let mut s = InlineSurface::empty(id, SurfaceKind::Text);
s.body = Some(scalar(Some(v)).unwrap_or_default());
return s;
};
match object.get("type").and_then(Value::as_str).unwrap_or("text") {
"card" => card(v, id),
"chart" => chart(v, id),
"form" => form(v, id, state),
"list" => list(v, id),
"metric" => metric(v, id),
"mindmap" => mindmap(v, id),
"table" => table(v, id),
"tabs" => tabs(v, id, state),
"text" => {
let mut s = InlineSurface::empty(id, SurfaceKind::Text);
s.body = string(v, "body").or_else(|| string(v, "text"));
s
}
_ => {
let mut s = InlineSurface::empty(id, SurfaceKind::Json);
s.raw = Some(Value::Object(object.clone()));
s
}
}
}
fn array<'a>(v: &'a Value, key: &str) -> &'a [Value] {
v.get(key)
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
}
fn string(v: &Value, key: &str) -> Option<String> {
scalar(v.get(key))
}
fn strings(v: Option<&Value>) -> Vec<String> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| scalar(Some(v)))
.collect()
}
fn scalar(v: Option<&Value>) -> Option<String> {
match v? {
Value::Null => None,
Value::String(v) => Some(v.clone()),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
v => serde_json::to_string(v).ok(),
}
}
fn numeric(v: Option<&Value>) -> f64 {
v.and_then(|v| {
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.filter(|v| v.is_finite())
.unwrap_or(0.0)
}
pub fn render_tool_result(name: &str, arguments: &Value) -> Option<Value> {
let kind = name.strip_prefix("render_")?;
RENDER_TOOL_NAMES.contains(&name).then(|| {
let mut values = arguments.as_object().cloned().unwrap_or_default();
values.insert("type".into(), Value::String(kind.into()));
Value::Object(values)
})
}

View File

@@ -9,7 +9,7 @@ use serde_json::{Value, json};
use crate::db::DbConnection as Connection;
use crate::db::queries::{media, post, post_link, post_media, script, template};
use crate::db::schema::ai_models;
use crate::engine::{EngineError, EngineResult};
use crate::engine::{EngineError, EngineResult, chat_surfaces};
use crate::util::frontmatter::read_post_file;
pub fn model_supports_tools(conn: &Connection, model: &str) -> EngineResult<bool> {
@@ -48,7 +48,7 @@ pub fn system_prompt(conn: &Connection, project_id: &str) -> EngineResult<String
.len();
let configured = crate::engine::settings::get(conn, "ai.system_prompt")?.unwrap_or_default();
let contract = format!(
"You are the conversational assistant for this blog project. Use tools when facts from the project are needed. Never invent project content or identifiers. There are {} posts, {media_count} media items, {tags} tags, and {categories} categories. Keep answers concise and use GitHub-flavored Markdown when useful.",
"You are the conversational assistant for this blog project. Use tools when facts from the project are needed. Never invent project content or identifiers. There are {} posts, {media_count} media items, {tags} tags, and {categories} categories. Keep answers concise and use GitHub-flavored Markdown when useful. Use render tools for structured data, comparisons, and forms; their payloads are native UI data, never HTML or JavaScript.",
posts.len()
);
Ok(if configured.trim().is_empty() {
@@ -168,6 +168,81 @@ pub fn tool_specs() -> Vec<Value> {
"value": {"type": "string"}
}),
),
spec(
"render_card",
"Render an information card with optional allow-listed actions.",
json!({
"title": {"type": "string"}, "subtitle": {"type": "string"}, "body": {"type": "string"},
"actions": {"type": "array", "items": {"type": "object", "properties": {
"label": {"type": "string"}, "action": {"type": "string"}, "payload": {"type": "object"}
}, "required": ["label", "action"]}}
}),
),
spec(
"render_chart",
"Render a native chart; heatmap and stacked-bar series use labelled segments.",
json!({
"chartType": {"type": "string", "enum": ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"]},
"chart_type": {"type": "string", "enum": ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"]},
"title": {"type": "string"},
"series": {"type": "array", "items": {"type": "object", "properties": {
"label": {"type": "string"}, "value": {"type": "number"},
"segments": {"type": "array", "items": {"type": "object", "properties": {
"label": {"type": "string"}, "value": {"type": "number"}
}, "required": ["label", "value"]}}
}, "required": ["label"]}}
}),
),
spec(
"render_form",
"Render a native form that submits its current values with an allow-listed action.",
json!({
"title": {"type": "string"},
"fields": {"type": "array", "items": {"type": "object", "properties": {
"key": {"type": "string"}, "label": {"type": "string"},
"inputType": {"type": "string", "enum": ["text", "textarea", "select", "checkbox", "date", "number"]},
"input_type": {"type": "string", "enum": ["text", "textarea", "select", "checkbox", "date", "number"]},
"placeholder": {"type": "string"}, "defaultValue": {}, "default_value": {}, "required": {"type": "boolean"},
"options": {"type": "array", "items": {"type": "object", "properties": {
"label": {"type": "string"}, "value": {"type": "string"}
}}}
}, "required": ["key", "label", "inputType"]}},
"submitLabel": {"type": "string"}, "submit_label": {"type": "string"},
"submitAction": {"type": "string"}, "submit_action": {"type": "string"}
}),
),
spec(
"render_list",
"Render a native list.",
json!({"title": {"type": "string"}, "items": {"type": "array", "items": {"type": "string"}}}),
),
spec(
"render_metric",
"Render a prominent metric.",
json!({"label": {"type": "string"}, "value": {"type": "string"}}),
),
spec(
"render_mindmap",
"Render a native hierarchical mind map.",
json!({"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object", "properties": {
"id": {"type": "string"}, "label": {"type": "string"}, "children": {"type": "array", "items": {"type": "string"}}
}, "required": ["label"]}}}),
),
spec(
"render_table",
"Render a native data table.",
json!({
"title": {"type": "string"}, "columns": {"type": "array", "items": {"type": "string"}},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}}
}),
),
spec(
"render_tabs",
"Render switchable tabs containing nested native surfaces or text.",
json!({"title": {"type": "string"}, "tabs": {"type": "array", "items": {"type": "object", "properties": {
"label": {"type": "string"}, "content": {"type": "array", "items": {"type": "object"}}
}, "required": ["label", "content"]}}}),
),
]
}
@@ -234,6 +309,10 @@ pub fn execute(
Ok(json!({"success": true, "script": item}))
}
"navigate" => navigate(arguments),
name if chat_surfaces::RENDER_TOOL_NAMES.contains(&name) => {
Ok(chat_surfaces::render_tool_result(name, arguments)
.expect("render tool allow-list and result builder must stay in sync"))
}
_ => Ok(json!({"success": false, "error": "unknown_tool", "name": name})),
}
}
@@ -858,4 +937,42 @@ mod tests {
);
assert!(matches!(invalid, Err(EngineError::Validation(_))));
}
#[test]
fn structured_render_tools_are_advertised_and_return_inert_native_data() {
let specs = tool_specs();
let names = specs
.iter()
.filter_map(|spec| spec.pointer("/function/name").and_then(Value::as_str))
.collect::<BTreeSet<_>>();
for name in chat_surfaces::RENDER_TOOL_NAMES {
assert!(names.contains(name), "missing tool schema for {name}");
}
let chart = specs
.iter()
.find(|spec| {
spec.pointer("/function/name").and_then(Value::as_str) == Some("render_chart")
})
.unwrap();
assert!(
chart
.pointer("/function/parameters/properties/chartType")
.is_some()
);
assert!(
chart
.pointer("/function/parameters/properties/chart_type")
.is_some()
);
let db = setup();
let dir = tempfile::tempdir().unwrap();
let raw = json!({"title": "<script>alert(1)</script>", "body": "<b>data</b>"});
let result = execute(db.conn(), dir.path(), "p1", "render_card", &raw).unwrap();
assert_eq!(result["type"], "card");
assert_eq!(result["title"], raw["title"]);
assert_eq!(result["body"], raw["body"]);
assert!(result.get("html").is_none());
assert!(result.get("javascript").is_none());
}
}

View File

@@ -3,6 +3,7 @@ pub mod auto_translation;
pub mod blogmark;
pub mod calendar;
pub mod chat;
pub mod chat_surfaces;
mod chat_tools;
pub mod cli_launcher;
pub mod cli_sync;

View File

@@ -57,6 +57,7 @@ pub struct ChatConversation {
pub title: String,
pub model: Option<String>,
pub copilot_session_id: Option<String>,
pub surface_state: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
@@ -68,6 +69,7 @@ pub struct NewChatConversation<'a> {
pub title: &'a str,
pub model: Option<&'a str>,
pub copilot_session_id: Option<&'a str>,
pub surface_state: Option<&'a str>,
pub created_at: i64,
pub updated_at: i64,
}

View File

@@ -397,6 +397,55 @@ fn tool_round_limit_stops_without_leaving_an_unpaired_tool_call() {
assert!(messages[1].tool_calls.as_deref().unwrap().contains("stats"));
}
#[test]
fn render_tool_events_include_stable_surface_identity_and_inert_arguments() {
let (_root, db, project_id, data_dir) = setup();
let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap();
let tool_frame = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"card\",\"function\":{\"name\":\"render_card\",\"arguments\":\"{\\\"title\\\":\\\"Live\\\",\\\"body\\\":\\\"<script>data only</script>\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n";
let (url, server) = serve(vec![
MockResponse::sse(vec![tool_frame]),
MockResponse::sse(vec![
"data: {\"choices\":[{\"delta\":{\"content\":\"Done\"}}]}\n\n",
"data: [DONE]\n\n",
]),
]);
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured = events.clone();
chat::send_chat_message(
db.conn(),
&data_dir,
&project_id,
false,
&conversation.id,
"Show it",
options(url, move |event| captured.lock().unwrap().push(event)),
)
.unwrap();
server.join().unwrap();
let messages = chat::list_messages(db.conn(), &conversation.id).unwrap();
let assistant_call = messages
.iter()
.find(|message| message.tool_calls.is_some())
.unwrap();
let events = events.lock().unwrap();
let event = events
.iter()
.find_map(|event| match event {
chat::ChatEvent::ToolStarted {
name,
surface_id,
arguments,
..
} if name == "render_card" => Some((surface_id, arguments)),
_ => None,
})
.unwrap();
assert_eq!(event.0, &format!("{}-surface-0", assistant_call.id));
assert_eq!(event.1["title"], "Live");
assert_eq!(event.1["body"], "<script>data only</script>");
}
#[test]
fn cancellation_before_mutating_tool_execution_records_pairs_without_mutation() {
let (_root, db, project_id, data_dir) = setup();

View File

@@ -0,0 +1,241 @@
use std::collections::{BTreeMap, BTreeSet};
use bds_core::db::Database;
use bds_core::engine::chat;
use bds_core::engine::chat_surfaces::{
ChartType, ChatSurfaceState, FormInputType, SurfaceKind, build_render_surface, merge_form_data,
resolve_surface_action,
};
use serde_json::{Value, json};
#[test]
fn every_render_tool_builds_the_expected_fixed_surface_type() {
let state = ChatSurfaceState::default();
for (index, (tool, expected)) in [
("render_card", SurfaceKind::Card),
("render_chart", SurfaceKind::Chart),
("render_form", SurfaceKind::Form),
("render_list", SurfaceKind::List),
("render_metric", SurfaceKind::Metric),
("render_mindmap", SurfaceKind::Mindmap),
("render_table", SurfaceKind::Table),
("render_tabs", SurfaceKind::Tabs),
]
.into_iter()
.enumerate()
{
let surface =
build_render_surface(tool, &json!({}), format!("message-surface-{index}"), &state)
.unwrap();
assert_eq!(surface.kind, expected);
}
assert!(build_render_surface("read_post", &json!({}), "x".into(), &state).is_none());
}
#[test]
fn charts_accept_all_types_aliases_and_coerce_invalid_numbers_to_zero() {
let state = ChatSurfaceState::default();
for chart_type in [
ChartType::Bar,
ChartType::StackedBar,
ChartType::Line,
ChartType::Area,
ChartType::Pie,
ChartType::Donut,
ChartType::Heatmap,
] {
let surface = build_render_surface(
"render_chart",
&json!({
"chartType": chart_type.as_str(),
"series": [{
"label": "A",
"value": "invalid",
"segments": [{"label": "one", "value": {}}, {"label": "two", "value": "2.5"}]
}]
}),
"chart".into(),
&state,
)
.unwrap();
assert_eq!(surface.chart_type, Some(chart_type));
assert_eq!(surface.series[0].value, 0.0);
assert_eq!(surface.series[0].segments[0].value, 0.0);
assert_eq!(surface.series[0].segments[1].value, 2.5);
}
let defaulted =
build_render_surface("render_chart", &json!({}), "default".into(), &state).unwrap();
assert_eq!(defaulted.chart_type, Some(ChartType::Bar));
let legacy = build_render_surface(
"render_chart",
&json!({"chart_type": "pie"}),
"legacy".into(),
&state,
)
.unwrap();
assert_eq!(legacy.chart_type, Some(ChartType::Pie));
}
#[test]
fn forms_support_every_input_type_and_restore_current_values() {
let mut state = ChatSurfaceState::default();
state.surface_data.insert(
"form".into(),
BTreeMap::from([
("title".into(), json!("Restored")),
("enabled".into(), json!(true)),
]),
);
let surface = build_render_surface(
"render_form",
&json!({
"fields": [
{"key": "title", "label": "Title", "inputType": "text", "defaultValue": "Default"},
{"key": "body", "label": "Body", "inputType": "textarea"},
{"key": "kind", "label": "Kind", "inputType": "select", "options": [{"label": "Post", "value": "post"}]},
{"key": "enabled", "label": "Enabled", "inputType": "checkbox"},
{"key": "date", "label": "Date", "inputType": "date"},
{"key": "count", "label": "Count", "inputType": "number"}
],
"submitAction": "openPost"
}),
"form".into(),
&state,
)
.unwrap();
assert_eq!(
surface
.fields
.iter()
.map(|field| field.input_type)
.collect::<Vec<_>>(),
vec![
FormInputType::Text,
FormInputType::Textarea,
FormInputType::Select,
FormInputType::Checkbox,
FormInputType::Date,
FormInputType::Number,
]
);
assert_eq!(surface.fields[0].value, json!("Restored"));
assert_eq!(surface.fields[3].value, json!(true));
let payload = merge_form_data(json!({"postId": "post-1"}), "form", &state);
assert_eq!(payload["postId"], "post-1");
assert_eq!(payload["formData"]["title"], "Restored");
assert_eq!(payload["formData"]["enabled"], true);
}
#[test]
fn tabs_restore_selection_and_nested_unknown_content_is_safe_data() {
let state = ChatSurfaceState {
surface_tabs: BTreeMap::from([("tabs".into(), 1)]),
..Default::default()
};
let surface = build_render_surface(
"render_tabs",
&json!({"tabs": [
{"label": "Known", "content": [{"type": "text", "body": "<script>bad()</script>"}]},
{"label": "Fallback", "content": [{"type": "future", "html": "<b>not markup</b>"}, 42]}
]}),
"tabs".into(),
&state,
)
.unwrap();
assert_eq!(surface.selected_index, Some(1));
assert_eq!(surface.tabs[0].content[0].kind, SurfaceKind::Text);
assert_eq!(
surface.tabs[0].content[0].body.as_deref(),
Some("<script>bad()</script>")
);
assert_eq!(surface.tabs[1].content[0].kind, SurfaceKind::Json);
assert_eq!(surface.tabs[1].content[1].kind, SurfaceKind::Text);
}
#[test]
fn unknown_render_tool_is_inspectable_json_but_non_render_tools_are_not_surfaces() {
let state = ChatSurfaceState::default();
let raw = json!({"html": "<script>alert(1)</script>", "answer": 42});
let surface = build_render_surface("render_future", &raw, "future-0".into(), &state).unwrap();
assert_eq!(surface.kind, SurfaceKind::Json);
assert_eq!(surface.raw.as_ref(), Some(&raw));
assert!(build_render_surface("run_script", &raw, "bad-0".into(), &state).is_none());
}
#[test]
fn surface_state_persists_and_reopens_against_stable_ids() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let conversation = chat::create_conversation(db.conn(), Some("model")).unwrap();
let expected = ChatSurfaceState {
surface_data: BTreeMap::from([(
"17-surface-0".into(),
BTreeMap::from([("query".into(), json!("hello"))]),
)]),
surface_tabs: BTreeMap::from([("17-surface-1".into(), 2)]),
dismissed_surfaces: BTreeSet::from(["17-surface-2".into()]),
};
chat::put_surface_state(db.conn(), &conversation.id, &expected).unwrap();
assert_eq!(
chat::get_surface_state(db.conn(), &conversation.id).unwrap(),
expected
);
assert_eq!(
chat::get_surface_state(db.conn(), "missing").unwrap(),
ChatSurfaceState::default()
);
}
#[test]
fn assistant_action_allow_list_accepts_aliases_and_refuses_bad_payloads() {
for (action, payload, destination, entity_id) in [
(
"openPost",
json!({"postId": "post-1"}),
"posts",
Some("post-1"),
),
(
"open_media",
json!({"media_id": "media-1"}),
"media",
Some("media-1"),
),
("openSettings", json!({}), "settings", None),
(
"open_chat",
json!({"conversationId": "chat-1"}),
"chat",
Some("chat-1"),
),
(
"switchView",
json!({"view": "templates"}),
"templates",
None,
),
("set_view", json!({"view": "scripts"}), "scripts", None),
("toggleSidebar", json!({}), "toggle_sidebar", None),
("toggle_panel", json!({}), "toggle_panel", None),
(
"toggleAssistantSidebar",
json!({}),
"toggle_assistant_sidebar",
None,
),
] {
let navigation = resolve_surface_action(action, &payload).unwrap();
assert_eq!(navigation.destination, destination);
assert_eq!(navigation.entity_id.as_deref(), entity_id);
}
for (action, payload) in [
("runJavaScript", json!({"code": "alert(1)"})),
("openPost", json!({"postId": ""})),
("switchView", json!({"view": "root-shell"})),
("openMedia", Value::String("media-1".into())),
] {
assert!(resolve_surface_action(action, &payload).is_err());
}
}