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