Skip to main content

systemprompt_models/artifacts/card/
mod.rs

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