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
11use crate::artifacts::metadata::ExecutionMetadata;
12use crate::artifacts::traits::Artifact;
13use crate::artifacts::types::ArtifactType;
14use crate::execution::context::RequestContext;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use serde_json::{Value as JsonValue, json};
18use systemprompt_identifiers::SkillId;
19
20#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
21pub struct PresentationCardResponse {
22    #[serde(rename = "x-artifact-type")]
23    pub artifact_type: String,
24    pub title: String,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub subtitle: Option<String>,
27    pub sections: Vec<CardSection>,
28    #[serde(skip_serializing_if = "Vec::is_empty", default)]
29    pub ctas: Vec<CardCta>,
30    pub theme: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub execution_id: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub skill_id: Option<SkillId>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub skill_name: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
40pub struct CardSection {
41    pub heading: String,
42    pub content: JsonValue,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub icon: Option<String>,
45}
46
47impl CardSection {
48    pub fn new(heading: impl Into<String>, content: impl Into<String>) -> Self {
49        Self {
50            heading: heading.into(),
51            content: JsonValue::String(content.into()),
52            icon: None,
53        }
54    }
55
56    #[must_use]
57    pub fn value(heading: impl Into<String>, content: JsonValue) -> Self {
58        Self {
59            heading: heading.into(),
60            content,
61            icon: None,
62        }
63    }
64
65    #[must_use]
66    pub fn content_display(&self) -> String {
67        match &self.content {
68            JsonValue::String(s) => s.clone(),
69            JsonValue::Null => String::new(),
70            other => other.to_string(),
71        }
72    }
73
74    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
75        self.icon = Some(icon.into());
76        self
77    }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81pub struct CardCta {
82    pub id: String,
83    pub label: String,
84    pub message: String,
85    pub variant: String,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub icon: Option<String>,
88}
89
90impl CardCta {
91    pub fn new(
92        id: impl Into<String>,
93        label: impl Into<String>,
94        message: impl Into<String>,
95        variant: impl Into<String>,
96    ) -> Self {
97        Self {
98            id: id.into(),
99            label: label.into(),
100            message: message.into(),
101            variant: variant.into(),
102            icon: None,
103        }
104    }
105
106    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
107        self.icon = Some(icon.into());
108        self
109    }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
113pub struct PresentationCardArtifact {
114    #[serde(rename = "x-artifact-type")]
115    #[serde(default = "default_card_artifact_type")]
116    pub artifact_type: String,
117    pub title: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub subtitle: Option<String>,
120    pub sections: Vec<CardSection>,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub ctas: Vec<CardCta>,
123    #[serde(default = "default_theme")]
124    pub theme: String,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub execution_id: Option<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub skill_id: Option<SkillId>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub skill_name: Option<String>,
131    #[serde(skip)]
132    #[schemars(skip)]
133    metadata: ExecutionMetadata,
134}
135
136fn default_theme() -> String {
137    "gradient".to_owned()
138}
139
140fn default_card_artifact_type() -> String {
141    "presentation_card".to_owned()
142}
143
144impl PresentationCardArtifact {
145    pub const ARTIFACT_TYPE_STR: &'static str = "presentation_card";
146
147    pub fn new(title: impl Into<String>) -> Self {
148        Self {
149            artifact_type: "presentation_card".to_owned(),
150            title: title.into(),
151            subtitle: None,
152            sections: Vec::new(),
153            ctas: Vec::new(),
154            theme: default_theme(),
155            execution_id: None,
156            skill_id: None,
157            skill_name: None,
158            metadata: ExecutionMetadata::default(),
159        }
160    }
161
162    pub fn with_request(mut self, ctx: &RequestContext) -> Self {
163        self.metadata = ExecutionMetadata::with_request(ctx);
164        self
165    }
166
167    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
168        self.subtitle = Some(subtitle.into());
169        self
170    }
171
172    pub fn with_sections(mut self, sections: Vec<CardSection>) -> Self {
173        self.sections = sections;
174        self
175    }
176
177    pub fn add_section(mut self, section: CardSection) -> Self {
178        self.sections.push(section);
179        self
180    }
181
182    pub fn with_ctas(mut self, ctas: Vec<CardCta>) -> Self {
183        self.ctas = ctas;
184        self
185    }
186
187    pub fn add_cta(mut self, cta: CardCta) -> Self {
188        self.ctas.push(cta);
189        self
190    }
191
192    pub fn with_theme(mut self, theme: impl Into<String>) -> Self {
193        self.theme = theme.into();
194        self
195    }
196
197    pub fn with_execution_id(mut self, id: impl Into<String>) -> Self {
198        let id_str = id.into();
199        self.execution_id = Some(id_str.clone());
200        self.metadata.execution_id = Some(id_str);
201        self
202    }
203
204    pub fn with_skill(
205        mut self,
206        skill_id: impl Into<SkillId>,
207        skill_name: impl Into<String>,
208    ) -> Self {
209        let id = skill_id.into();
210        self.skill_id = Some(id.clone());
211        self.skill_name = Some(skill_name.into());
212        self.metadata.skill_id = Some(id);
213        self
214    }
215}
216
217impl Artifact for PresentationCardArtifact {
218    fn artifact_type(&self) -> ArtifactType {
219        ArtifactType::PresentationCard
220    }
221
222    fn to_schema(&self) -> JsonValue {
223        json!({
224            "type": "object",
225            "properties": {
226                "title": {
227                    "type": "string",
228                    "description": "Card title"
229                },
230                "subtitle": {
231                    "type": "string",
232                    "description": "Card subtitle"
233                },
234                "sections": {
235                    "type": "array",
236                    "description": "Content sections",
237                    "items": {
238                        "type": "object",
239                        "properties": {
240                            "heading": {"type": "string"},
241                            "content": {"description": "Section content: plain string or structured JSON"},
242                            "icon": {"type": "string"}
243                        },
244                        "required": ["heading", "content"]
245                    }
246                },
247                "ctas": {
248                    "type": "array",
249                    "description": "Call-to-action buttons",
250                    "items": {
251                        "type": "object",
252                        "properties": {
253                            "id": {"type": "string"},
254                            "label": {"type": "string"},
255                            "message": {"type": "string"},
256                            "variant": {"type": "string"},
257                            "icon": {"type": "string"}
258                        },
259                        "required": ["id", "label", "message", "variant"]
260                    }
261                },
262                "theme": {
263                    "type": "string",
264                    "description": "Card theme",
265                    "default": "gradient"
266                },
267                "_execution_id": {
268                    "type": "string",
269                    "description": "Execution ID for tracking"
270                }
271            },
272            "required": ["title", "sections"],
273            "x-artifact-type": "presentation_card",
274            "x-presentation-hints": {
275                "theme": self.theme
276            }
277        })
278    }
279}