1use serde::Deserialize;
4use serde::Serialize;
5
6use super::EventMsg;
7use super::ModelStepOutcome;
8use super::Op;
9use super::SessionFileReference;
10use super::WebSearchAction;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct FrontendCommand {
15 pub name: String,
16 pub arguments: String,
17 pub description: String,
18 pub requires_idle: bool,
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct FrontendContribution {
25 pub capability: String,
26 pub accepts_file_attachments: bool,
28 pub count: Option<usize>,
30 pub commands: Vec<FrontendCommand>,
31 pub widgets: Vec<FrontendWidget>,
32 pub references: Vec<FrontendReference>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct MiddlewareFeature {
38 pub id: String,
39 pub label: String,
40 pub description: String,
41 pub required: bool,
42 pub settings: Vec<FrontendSetting>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct FrontendSetting {
48 pub id: String,
49 pub label: String,
50 pub description: String,
51 pub composer: bool,
53 #[serde(flatten)]
54 pub kind: FrontendSettingKind,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
60pub enum FrontendSettingKind {
61 Integer {
62 min: i64,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 max: Option<i64>,
65 step: i64,
66 },
67 Select {
68 options: Vec<FrontendSettingOption>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 unset_label: Option<String>,
71 },
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct FrontendSettingOption {
77 pub value: String,
78 pub label: String,
79 pub description: String,
80 pub symbol: Option<FrontendSymbol>,
81 pub tone: FrontendTone,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(untagged)]
87pub enum FrontendSettingValue {
88 Integer(i64),
89 String(String),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct FrontendReference {
95 pub trigger: char,
96 pub value: String,
97 pub description: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct FrontendWidget {
103 pub id: String,
104 pub slot: FrontendSlot,
105 pub text: String,
106 pub tone: FrontendTone,
107 pub symbol: Option<FrontendSymbol>,
108 pub icon_only: bool,
109 pub progress: Option<FrontendProgress>,
110 pub content: Option<FrontendWidgetContent>,
111 pub action: Option<Op>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117pub struct FrontendProgress {
118 pub completed: usize,
119 pub total: usize,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(tag = "type", rename_all = "snake_case")]
125pub enum FrontendWidgetContent {
126 Blocks {
127 title: String,
128 blocks: Vec<FrontendBlock>,
129 },
130 Picker {
131 title: String,
132 options: Vec<FrontendPickerOption>,
133 },
134 ActionList {
135 title: String,
136 items: Vec<FrontendActionListItem>,
137 },
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum FrontendSlot {
144 Header,
145 ComposerHeader,
146 ComposerFooter,
147 MessageActions,
148 TranscriptTail,
150 Navigation,
152 ChatMenu,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct FrontendBlock {
159 pub id: Option<String>,
160 pub group: Option<String>,
161 pub update: FrontendBlockUpdate,
162 pub state: FrontendBlockState,
163 pub role: FrontendBlockRole,
164 pub title: String,
166 pub text: String,
168 pub symbol: Option<FrontendSymbol>,
169 pub files: Vec<SessionFileReference>,
171 pub format: FrontendBlockFormat,
172 pub tone: FrontendTone,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct RenderedBlock {
178 pub capability: String,
179 pub block: FrontendBlock,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum FrontendBlockUpdate {
186 Replace,
187 Append,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum FrontendBlockState {
194 Pending,
195 Complete,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum FrontendBlockRole {
202 Activity,
203 Tool,
204 WebSearch,
205 Artifact,
206 Approval,
207 Notice,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "snake_case")]
213pub enum FrontendBlockFormat {
214 PlainText,
215 UnifiedDiff,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct FrontendPickerOption {
221 pub label: String,
222 pub description: String,
223 pub detail: String,
224 pub symbol: Option<FrontendSymbol>,
225 pub shows_detail: bool,
226 pub op: Op,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct FrontendActionListItem {
232 pub id: String,
233 pub text: String,
234 pub state: FrontendListItemState,
235 pub actions: Vec<FrontendAction>,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum FrontendListItemState {
242 Plain,
243 Pending,
244 InProgress,
245 Completed,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct FrontendAction {
251 pub id: String,
252 pub label: String,
253 pub symbol: FrontendSymbol,
254 pub tone: FrontendTone,
255 pub op: Op,
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct FrontendPreviewEvent {
261 pub recorded_at_ms: i64,
262 pub event: EventMsg,
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267#[serde(tag = "frontend_type", rename_all = "snake_case")]
268pub enum FrontendEvent {
269 Render {
270 capability: String,
271 block: FrontendBlock,
272 },
273 Widget {
274 capability: String,
275 item: FrontendWidget,
276 },
277 RemoveWidget {
278 capability: String,
279 id: String,
280 },
281 Picker {
282 title: String,
283 options: Vec<FrontendPickerOption>,
284 },
285 Preview {
286 id: String,
287 title: String,
288 subtitle: String,
289 page_id: String,
290 update: FrontendPreviewUpdate,
291 events: Vec<FrontendPreviewEvent>,
292 next: Option<Op>,
293 },
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "snake_case")]
299pub enum FrontendPreviewUpdate {
300 Replace,
301 Prepend,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "snake_case")]
307pub enum FrontendTone {
308 Neutral,
309 Success,
310 Warning,
311 Error,
312}
313
314impl EventMsg {
315 #[must_use]
317 pub fn presentation(&self) -> Option<RenderedBlock> {
318 let block = match self {
319 Self::Error(error) => FrontendBlock {
320 id: None,
321 group: None,
322 update: FrontendBlockUpdate::Replace,
323 state: FrontendBlockState::Complete,
324 role: FrontendBlockRole::Notice,
325 title: "Error".into(),
326 text: error.message.clone(),
327 symbol: None,
328 files: Vec::new(),
329 format: FrontendBlockFormat::PlainText,
330 tone: FrontendTone::Error,
331 },
332 Self::Warning(warning) => FrontendBlock {
333 id: None,
334 group: None,
335 update: FrontendBlockUpdate::Replace,
336 state: FrontendBlockState::Complete,
337 role: FrontendBlockRole::Notice,
338 title: "Warning".into(),
339 text: warning.message.clone(),
340 symbol: None,
341 files: Vec::new(),
342 format: FrontendBlockFormat::PlainText,
343 tone: FrontendTone::Warning,
344 },
345 Self::TurnAborted(turn) => FrontendBlock {
346 id: None,
347 group: Some(turn.turn_id.clone()),
348 update: FrontendBlockUpdate::Replace,
349 state: FrontendBlockState::Complete,
350 role: FrontendBlockRole::Notice,
351 title: "Turn aborted".into(),
352 text: turn.reason.clone(),
353 symbol: None,
354 files: Vec::new(),
355 format: FrontendBlockFormat::PlainText,
356 tone: FrontendTone::Warning,
357 },
358 Self::ModelStepCompleted(step) if step.outcome == ModelStepOutcome::Retrying => {
359 FrontendBlock {
360 id: Some(format!("{}/retry", step.model_step_id)),
361 group: Some(step.turn_id.clone()),
362 update: FrontendBlockUpdate::Replace,
363 state: FrontendBlockState::Complete,
364 role: FrontendBlockRole::Notice,
365 title: "Reconnecting…".into(),
366 text: String::new(),
367 symbol: None,
368 files: Vec::new(),
369 format: FrontendBlockFormat::PlainText,
370 tone: FrontendTone::Warning,
371 }
372 }
373 Self::WebSearchBegin(search) => FrontendBlock {
374 id: Some(format!("{}/{}", search.model_step_id, search.call_id)),
375 group: Some(search.turn_id.clone()),
376 update: FrontendBlockUpdate::Replace,
377 state: FrontendBlockState::Pending,
378 role: FrontendBlockRole::WebSearch,
379 title: "Searching the web".into(),
380 text: String::new(),
381 symbol: Some(FrontendSymbol::Search),
382 files: Vec::new(),
383 format: FrontendBlockFormat::PlainText,
384 tone: FrontendTone::Neutral,
385 },
386 Self::WebSearchEnd(search) => {
387 let (title, text, tone) = match &search.action {
388 WebSearchAction::Search { queries } => (
389 "Searched the web",
390 queries.join("\n"),
391 FrontendTone::Success,
392 ),
393 WebSearchAction::OpenPage { url } => (
394 "Opened a web page",
395 url.clone().unwrap_or_default(),
396 FrontendTone::Success,
397 ),
398 WebSearchAction::FindInPage { url, pattern } => {
399 let text = match (url, pattern) {
400 (Some(url), Some(pattern)) => format!("{pattern}\n{url}"),
401 (Some(url), None) => url.clone(),
402 (None, Some(pattern)) => pattern.clone(),
403 (None, None) => String::new(),
404 };
405 ("Searched a web page", text, FrontendTone::Success)
406 }
407 WebSearchAction::Interrupted => (
408 "Web search interrupted",
409 String::new(),
410 FrontendTone::Warning,
411 ),
412 WebSearchAction::Other => {
413 ("Web search complete", String::new(), FrontendTone::Success)
414 }
415 };
416 FrontendBlock {
417 id: Some(format!("{}/{}", search.model_step_id, search.call_id)),
418 group: Some(search.turn_id.clone()),
419 update: FrontendBlockUpdate::Replace,
420 state: FrontendBlockState::Complete,
421 role: FrontendBlockRole::WebSearch,
422 title: title.into(),
423 text,
424 symbol: Some(FrontendSymbol::Search),
425 files: Vec::new(),
426 format: FrontendBlockFormat::PlainText,
427 tone,
428 }
429 }
430 Self::Frontend(FrontendEvent::Render { capability, block }) => {
431 return Some(RenderedBlock {
432 capability: capability.clone(),
433 block: block.clone(),
434 });
435 }
436 _ => return None,
437 };
438 Some(RenderedBlock {
439 capability: match self {
440 Self::WebSearchBegin(_) | Self::WebSearchEnd(_) => "web_search",
441 _ => "agent",
442 }
443 .into(),
444 block,
445 })
446 }
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
459pub enum FrontendSymbol {
460 Agent,
461 Brain,
462 Branch,
463 Chat,
464 Delete,
465 Edit,
466 Promote,
467 Route,
468 Search,
469 Shield,
470 ShieldAlert,
471 ShieldCheck,
472 ShieldOff,
473 Sparkle,
474 Storage,
475 Task,
476 Custom(String),
477}
478
479impl FrontendSymbol {
480 pub fn as_str(&self) -> &str {
482 match self {
483 Self::Agent => "agent",
484 Self::Brain => "brain",
485 Self::Branch => "branch",
486 Self::Chat => "chat",
487 Self::Delete => "delete",
488 Self::Edit => "edit",
489 Self::Promote => "promote",
490 Self::Route => "route",
491 Self::Search => "search",
492 Self::Shield => "shield",
493 Self::ShieldAlert => "shield_alert",
494 Self::ShieldCheck => "shield_check",
495 Self::ShieldOff => "shield_off",
496 Self::Sparkle => "sparkle",
497 Self::Storage => "storage",
498 Self::Task => "task",
499 Self::Custom(name) => name,
500 }
501 }
502
503 pub(crate) fn from_wire(name: &str) -> Self {
506 match name {
507 "agent" => Self::Agent,
508 "brain" => Self::Brain,
509 "branch" => Self::Branch,
510 "chat" => Self::Chat,
511 "delete" => Self::Delete,
512 "edit" => Self::Edit,
513 "promote" => Self::Promote,
514 "route" => Self::Route,
515 "search" => Self::Search,
516 "shield" => Self::Shield,
517 "shield_alert" => Self::ShieldAlert,
518 "shield_check" => Self::ShieldCheck,
519 "shield_off" => Self::ShieldOff,
520 "sparkle" => Self::Sparkle,
521 "storage" => Self::Storage,
522 "task" => Self::Task,
523 other => Self::Custom(other.to_owned()),
524 }
525 }
526}
527
528impl std::fmt::Display for FrontendSymbol {
529 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
530 formatter.write_str(self.as_str())
531 }
532}
533
534impl Serialize for FrontendSymbol {
535 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
536 serializer.serialize_str(self.as_str())
537 }
538}
539
540impl<'de> Deserialize<'de> for FrontendSymbol {
541 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
542 String::deserialize(deserializer).map(|name| Self::from_wire(&name))
545 }
546}