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    pub content: JsonValue,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub icon: Option<String>,
100}
101
102impl CardSection {
103    pub fn new(heading: impl Into<String>, content: impl Into<String>) -> Self {
104        Self {
105            heading: heading.into(),
106            content: JsonValue::String(content.into()),
107            icon: None,
108        }
109    }
110
111    #[must_use]
112    pub fn value(heading: impl Into<String>, content: JsonValue) -> Self {
113        Self {
114            heading: heading.into(),
115            content,
116            icon: None,
117        }
118    }
119
120    #[must_use]
121    pub fn content_display(&self) -> String {
122        match &self.content {
123            JsonValue::String(s) => s.clone(),
124            JsonValue::Null => String::new(),
125            JsonValue::Array(items) => items
126                .iter()
127                .map(Self::scalar_text)
128                .collect::<Vec<_>>()
129                .join(", "),
130            other => Self::scalar_text(other),
131        }
132    }
133
134    fn scalar_text(value: &JsonValue) -> String {
135        match value {
136            JsonValue::String(s) => s.clone(),
137            JsonValue::Null => String::new(),
138            other => other.to_string(),
139        }
140    }
141
142    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
143        self.icon = Some(icon.into());
144        self
145    }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
149pub struct CardCta {
150    pub id: String,
151    pub label: String,
152    pub message: String,
153    pub variant: CtaVariant,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub icon: Option<String>,
156}
157
158impl CardCta {
159    pub fn new(
160        id: impl Into<String>,
161        label: impl Into<String>,
162        message: impl Into<String>,
163        variant: CtaVariant,
164    ) -> Self {
165        Self {
166            id: id.into(),
167            label: label.into(),
168            message: message.into(),
169            variant,
170            icon: None,
171        }
172    }
173
174    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
175        self.icon = Some(icon.into());
176        self
177    }
178}