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 pub disables: Vec<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(untagged)]
89pub enum FrontendSettingValue {
90 Integer(i64),
91 String(String),
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct FrontendReference {
97 pub trigger: char,
98 pub value: String,
99 pub description: String,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct FrontendWidget {
105 pub id: String,
106 pub slot: FrontendSlot,
107 pub text: String,
108 pub tone: FrontendTone,
109 pub symbol: Option<FrontendSymbol>,
110 pub icon_only: bool,
111 pub progress: Option<FrontendProgress>,
112 pub content: Option<FrontendWidgetContent>,
113 pub action: Option<Op>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119pub struct FrontendProgress {
120 pub completed: usize,
121 pub total: usize,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(tag = "type", rename_all = "snake_case")]
127pub enum FrontendWidgetContent {
128 Blocks {
129 title: String,
130 blocks: Vec<FrontendBlock>,
131 },
132 Picker {
133 title: String,
134 options: Vec<FrontendPickerOption>,
135 },
136 ActionList {
137 title: String,
138 items: Vec<FrontendActionListItem>,
139 actions: Vec<FrontendAction>,
141 },
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum FrontendSlot {
148 Header,
149 ComposerHeader,
150 ComposerFooter,
151 MessageActions,
152 TranscriptTail,
154 Navigation,
156 ChatMenu,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct FrontendBlock {
163 pub id: Option<String>,
164 pub group: Option<String>,
165 pub update: FrontendBlockUpdate,
166 pub state: FrontendBlockState,
167 pub role: FrontendBlockRole,
168 pub title: String,
170 pub text: String,
172 pub symbol: Option<FrontendSymbol>,
173 pub files: Vec<SessionFileReference>,
175 pub format: FrontendBlockFormat,
176 pub tone: FrontendTone,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct RenderedBlock {
182 pub capability: String,
183 pub block: FrontendBlock,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum FrontendBlockUpdate {
190 Replace,
191 Append,
193}
194
195impl FrontendBlockUpdate {
196 pub fn apply(self, current: &mut String, text: &str) {
198 if self == Self::Replace {
199 current.clear();
200 } else if !current.is_empty()
201 && !text.is_empty()
202 && !current.ends_with('\n')
203 && !text.starts_with('\n')
204 {
205 current.push('\n');
206 }
207 current.push_str(text);
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub enum FrontendBlockState {
215 Pending,
216 Complete,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum FrontendBlockRole {
223 Activity,
224 Tool,
225 WebSearch,
226 Artifact,
227 Approval,
228 Notice,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum FrontendBlockFormat {
235 PlainText,
236 UnifiedDiff,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct FrontendPickerOption {
242 pub label: String,
243 pub description: String,
244 pub detail: String,
245 pub symbol: Option<FrontendSymbol>,
246 pub shows_detail: bool,
247 pub op: Op,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct FrontendActionListItem {
253 pub id: String,
254 pub text: String,
255 pub state: FrontendListItemState,
256 pub actions: Vec<FrontendAction>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "snake_case")]
262pub enum FrontendListItemState {
263 Plain,
264 Pending,
265 InProgress,
266 Completed,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct FrontendAction {
272 pub id: String,
273 pub label: String,
274 pub symbol: FrontendSymbol,
275 pub tone: FrontendTone,
276 pub op: Op,
277 pub editor: Option<FrontendEditor>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct FrontendEditor {
284 pub title: String,
285 pub label: String,
286 pub description: String,
287 pub submit_label: String,
288}
289
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
292pub struct FrontendPreviewEvent {
293 pub submission_id: Option<String>,
295 pub recorded_at_ms: i64,
296 pub event: EventMsg,
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(tag = "frontend_type", rename_all = "snake_case")]
302pub enum FrontendEvent {
303 Render {
304 capability: String,
305 block: FrontendBlock,
306 },
307 Widget {
308 capability: String,
309 item: FrontendWidget,
310 },
311 RemoveWidget {
312 capability: String,
313 id: String,
314 },
315 Picker {
316 title: String,
317 options: Vec<FrontendPickerOption>,
318 },
319 Preview {
320 id: String,
321 title: String,
322 subtitle: String,
323 page_id: String,
324 update: FrontendPreviewUpdate,
325 events: Vec<FrontendPreviewEvent>,
326 next: Option<Op>,
327 },
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum FrontendPreviewUpdate {
334 Replace,
335 Prepend,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "snake_case")]
341pub enum FrontendTone {
342 Neutral,
343 Success,
344 Warning,
345 Error,
346}
347
348impl EventMsg {
349 #[must_use]
351 pub fn presentation(&self) -> Option<RenderedBlock> {
352 let block = match self {
353 Self::Error(error) => FrontendBlock {
354 id: None,
355 group: None,
356 update: FrontendBlockUpdate::Replace,
357 state: FrontendBlockState::Complete,
358 role: FrontendBlockRole::Notice,
359 title: "Error".into(),
360 text: error.message.clone(),
361 symbol: None,
362 files: Vec::new(),
363 format: FrontendBlockFormat::PlainText,
364 tone: FrontendTone::Error,
365 },
366 Self::Warning(warning) => FrontendBlock {
367 id: None,
368 group: None,
369 update: FrontendBlockUpdate::Replace,
370 state: FrontendBlockState::Complete,
371 role: FrontendBlockRole::Notice,
372 title: "Warning".into(),
373 text: warning.message.clone(),
374 symbol: None,
375 files: Vec::new(),
376 format: FrontendBlockFormat::PlainText,
377 tone: FrontendTone::Warning,
378 },
379 Self::TurnAborted(turn) => FrontendBlock {
380 id: None,
381 group: Some(turn.turn_id.clone()),
382 update: FrontendBlockUpdate::Replace,
383 state: FrontendBlockState::Complete,
384 role: FrontendBlockRole::Notice,
385 title: "Turn aborted".into(),
386 text: turn.reason.clone(),
387 symbol: None,
388 files: Vec::new(),
389 format: FrontendBlockFormat::PlainText,
390 tone: FrontendTone::Warning,
391 },
392 Self::ModelStepCompleted(step) if step.outcome == ModelStepOutcome::Retrying => {
393 FrontendBlock {
394 id: Some(format!("{}/retry", step.model_step_id)),
395 group: Some(step.turn_id.clone()),
396 update: FrontendBlockUpdate::Replace,
397 state: FrontendBlockState::Complete,
398 role: FrontendBlockRole::Notice,
399 title: "Reconnecting…".into(),
400 text: String::new(),
401 symbol: None,
402 files: Vec::new(),
403 format: FrontendBlockFormat::PlainText,
404 tone: FrontendTone::Warning,
405 }
406 }
407 Self::WebSearchBegin(search) => FrontendBlock {
408 id: Some(format!("{}/{}", search.model_step_id, search.call_id)),
409 group: Some(search.turn_id.clone()),
410 update: FrontendBlockUpdate::Replace,
411 state: FrontendBlockState::Pending,
412 role: FrontendBlockRole::WebSearch,
413 title: "Searching the web".into(),
414 text: String::new(),
415 symbol: Some(FrontendSymbol::Search),
416 files: Vec::new(),
417 format: FrontendBlockFormat::PlainText,
418 tone: FrontendTone::Neutral,
419 },
420 Self::WebSearchEnd(search) => {
421 let (title, text, tone) = match &search.action {
422 WebSearchAction::Search { queries } => (
423 "Searched the web",
424 queries.join("\n"),
425 FrontendTone::Success,
426 ),
427 WebSearchAction::OpenPage { url } => (
428 "Opened a web page",
429 url.clone().unwrap_or_default(),
430 FrontendTone::Success,
431 ),
432 WebSearchAction::FindInPage { url, pattern } => {
433 let text = match (url, pattern) {
434 (Some(url), Some(pattern)) => format!("{pattern}\n{url}"),
435 (Some(url), None) => url.clone(),
436 (None, Some(pattern)) => pattern.clone(),
437 (None, None) => String::new(),
438 };
439 ("Searched a web page", text, FrontendTone::Success)
440 }
441 WebSearchAction::Interrupted => (
442 "Web search interrupted",
443 String::new(),
444 FrontendTone::Warning,
445 ),
446 WebSearchAction::Other => {
447 ("Web search complete", String::new(), FrontendTone::Success)
448 }
449 };
450 FrontendBlock {
451 id: Some(format!("{}/{}", search.model_step_id, search.call_id)),
452 group: Some(search.turn_id.clone()),
453 update: FrontendBlockUpdate::Replace,
454 state: FrontendBlockState::Complete,
455 role: FrontendBlockRole::WebSearch,
456 title: title.into(),
457 text,
458 symbol: Some(FrontendSymbol::Search),
459 files: Vec::new(),
460 format: FrontendBlockFormat::PlainText,
461 tone,
462 }
463 }
464 Self::Frontend(FrontendEvent::Render { capability, block }) => {
465 return Some(RenderedBlock {
466 capability: capability.clone(),
467 block: block.clone(),
468 });
469 }
470 _ => return None,
471 };
472 Some(RenderedBlock {
473 capability: match self {
474 Self::WebSearchBegin(_) | Self::WebSearchEnd(_) => "web_search",
475 _ => "agent",
476 }
477 .into(),
478 block,
479 })
480 }
481}
482
483#[derive(Debug, Clone, PartialEq, Eq)]
493pub enum FrontendSymbol {
494 Agent,
495 Brain,
496 Branch,
497 Chat,
498 Delete,
499 Edit,
500 Promote,
501 Route,
502 Search,
503 Shield,
504 ShieldAlert,
505 ShieldCheck,
506 ShieldOff,
507 Sparkle,
508 Storage,
509 Task,
510 Custom(String),
511}
512
513impl FrontendSymbol {
514 pub fn as_str(&self) -> &str {
516 match self {
517 Self::Agent => "agent",
518 Self::Brain => "brain",
519 Self::Branch => "branch",
520 Self::Chat => "chat",
521 Self::Delete => "delete",
522 Self::Edit => "edit",
523 Self::Promote => "promote",
524 Self::Route => "route",
525 Self::Search => "search",
526 Self::Shield => "shield",
527 Self::ShieldAlert => "shield_alert",
528 Self::ShieldCheck => "shield_check",
529 Self::ShieldOff => "shield_off",
530 Self::Sparkle => "sparkle",
531 Self::Storage => "storage",
532 Self::Task => "task",
533 Self::Custom(name) => name,
534 }
535 }
536
537 pub(crate) fn from_wire(name: &str) -> Self {
540 match name {
541 "agent" => Self::Agent,
542 "brain" => Self::Brain,
543 "branch" => Self::Branch,
544 "chat" => Self::Chat,
545 "delete" => Self::Delete,
546 "edit" => Self::Edit,
547 "promote" => Self::Promote,
548 "route" => Self::Route,
549 "search" => Self::Search,
550 "shield" => Self::Shield,
551 "shield_alert" => Self::ShieldAlert,
552 "shield_check" => Self::ShieldCheck,
553 "shield_off" => Self::ShieldOff,
554 "sparkle" => Self::Sparkle,
555 "storage" => Self::Storage,
556 "task" => Self::Task,
557 other => Self::Custom(other.to_owned()),
558 }
559 }
560}
561
562impl std::fmt::Display for FrontendSymbol {
563 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564 formatter.write_str(self.as_str())
565 }
566}
567
568impl Serialize for FrontendSymbol {
569 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
570 serializer.serialize_str(self.as_str())
571 }
572}
573
574impl<'de> Deserialize<'de> for FrontendSymbol {
575 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
576 String::deserialize(deserializer).map(|name| Self::from_wire(&name))
579 }
580}