Skip to main content

systemprompt_models/artifacts/card/
mod.rs

1//! Presentation-card artifact.
2//!
3//! A [`PresentationCardArtifact`] renders a titled card composed of
4//! [`CardSection`]s and optional [`CardCta`] action buttons under a named
5//! theme. [`PresentationCardResponse`] is the matching deserialization shape
6//! for tool output.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11mod artifact;
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use serde_json::Value as JsonValue;
16use systemprompt_identifiers::SkillId;
17
18pub use artifact::PresentationCardArtifact;
19
20/// A card's visual treatment.
21///
22/// This was a free `String` interpolated straight into a `card-theme-{}` class
23/// name, so any value the stylesheet did not happen to define produced a class
24/// with no rules and a silently unstyled card. The renderer now cannot be
25/// handed a value it has no treatment for.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
27#[serde(rename_all = "lowercase")]
28pub enum CardTheme {
29    #[default]
30    Gradient,
31    Plain,
32    Muted,
33    #[serde(other)]
34    Unknown,
35}
36
37impl CardTheme {
38    #[must_use]
39    pub const fn class_suffix(self) -> &'static str {
40        match self {
41            Self::Gradient | Self::Unknown => "gradient",
42            Self::Plain => "plain",
43            Self::Muted => "muted",
44        }
45    }
46}
47
48/// A CTA button's visual weight.
49///
50/// Same defect as [`CardTheme`]: `"secondary"` was accepted, rendered as
51/// `card-cta-secondary`, and had no rule — which is why the email draft's
52/// Discard button came out unstyled.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
54#[serde(rename_all = "lowercase")]
55pub enum CtaVariant {
56    #[default]
57    Primary,
58    Secondary,
59    Danger,
60    #[serde(other)]
61    Unknown,
62}
63
64impl CtaVariant {
65    #[must_use]
66    pub const fn class_suffix(self) -> &'static str {
67        match self {
68            Self::Primary | Self::Unknown => "primary",
69            Self::Secondary => "secondary",
70            Self::Danger => "danger",
71        }
72    }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
76pub struct PresentationCardResponse {
77    #[serde(rename = "x-artifact-type")]
78    pub artifact_type: String,
79    pub title: String,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub subtitle: Option<String>,
82    pub sections: Vec<CardSection>,
83    #[serde(skip_serializing_if = "Vec::is_empty", default)]
84    pub ctas: Vec<CardCta>,
85    pub theme: CardTheme,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub execution_id: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub skill_id: Option<SkillId>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub skill_name: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
95pub struct CardSection {
96    pub heading: String,
97    // JSON: Card field content is any JSON scalar or object the tool emitted.
98    pub content: JsonValue,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub icon: Option<String>,
101}
102
103impl CardSection {
104    pub fn new(heading: impl Into<String>, content: impl Into<String>) -> Self {
105        Self {
106            heading: heading.into(),
107            content: JsonValue::String(content.into()),
108            icon: None,
109        }
110    }
111
112    #[must_use]
113    // JSON: Card field content is any JSON scalar or object the tool emitted.
114    pub fn value(heading: impl Into<String>, content: JsonValue) -> Self {
115        Self {
116            heading: heading.into(),
117            content,
118            icon: None,
119        }
120    }
121
122    #[must_use]
123    pub fn content_display(&self) -> String {
124        match &self.content {
125            JsonValue::String(s) => s.clone(),
126            JsonValue::Null => String::new(),
127            JsonValue::Array(items) => items
128                .iter()
129                .map(Self::scalar_text)
130                .collect::<Vec<_>>()
131                .join(", "),
132            other => Self::scalar_text(other),
133        }
134    }
135
136    // JSON: Card field content is any JSON scalar or object the tool emitted.
137    fn scalar_text(value: &JsonValue) -> String {
138        match value {
139            JsonValue::String(s) => s.clone(),
140            JsonValue::Null => String::new(),
141            other => other.to_string(),
142        }
143    }
144
145    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
146        self.icon = Some(icon.into());
147        self
148    }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
152pub struct CardCta {
153    pub id: String,
154    pub label: String,
155    pub message: String,
156    pub variant: CtaVariant,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub icon: Option<String>,
159}
160
161impl CardCta {
162    pub fn new(
163        id: impl Into<String>,
164        label: impl Into<String>,
165        message: impl Into<String>,
166        variant: CtaVariant,
167    ) -> Self {
168        Self {
169            id: id.into(),
170            label: label.into(),
171            message: message.into(),
172            variant,
173            icon: None,
174        }
175    }
176
177    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
178        self.icon = Some(icon.into());
179        self
180    }
181}