// 對話內容, like the original (captured from Grok Bot itself): // • the bot's words in grey bubbles on the left (no avatar, no model name, no tool rows); // the person's in the bot's colour on the right, text always reading left to right; // • the date in small grey text between messages that are far apart; // • on hover, three small buttons next to the bubble: 表情回應, 回覆, ⋯ (steps and thinking // live behind ⋯); // • files as cards that open a preview; // • while it works: a spinning ring and a phrase that shimmers ("正在讀取檔案"…), picked from // the original's own list by what the bot is doing; // • motion from the original's tokens: rows fade/rise in over 240 ms, a reaction pops in over // 500 ms with a little overshoot, the ring turns every 1.2 s, the shimmer sweeps every 1.4 s. use super::*; const ROW_ENTER: f32 = 0.24; const REACTION_POP: f32 = 0.5; const SPIN_SECS: f64 = 1.2; const SHIMMER_SECS: f64 = 1.4; /// Reactions offered at once (the rest are in "更多表情符號"). const QUICK: [&str; 6] = ["❤️", "👍", "😂", "🎉", "🙏", "👀"]; const MORE: [&str; 24] = ["😊", "😍", "🥹", "😮", "😢", "😡", "🤔", "👏", "🙌", "💪", "🔥", "✨", "✅", "❌", "💯", "🚀", "☕", "🍰", "🌟", "💡", "📌", "👌", "🤝", "🫶"]; /// The original's status phrases (renderer table vit). const THINKING: [&str; 3] = ["正在仔細思考", "正在琢磨", "正在整理思緒"]; const WORKING: [&str; 5] = ["正在完成", "正在推進", "正在處理", "正在搞定", "正在一點一點推進"]; /// What the bot is doing, as the original says it. pub fn status_phrase(tool: Option<&str>, on_box: bool, waiting: bool, has_text: bool, seed: u64) -> String { let pick = |list: &[&str]| list[(seed as usize) % list.len()].to_string(); if waiting { return "需要你的輸入".into(); } let Some(name) = tool else { return if has_text { pick(&WORKING) } else { pick(&THINKING) } }; match name { "read_file" | "list_dir" | "search" => "正在讀取檔案".into(), "edit_file" | "send_file" => "正在記下來".into(), "shell" | "write_terminal" => "正在執行幾個指令".into(), "read_terminal" => "正在等待結果".into(), "web_fetch" | "browser_read" => "正在閱讀資料".into(), "browser_open" => "正在逐頁瀏覽".into(), "message_bot" => "正在詢問另一個 Bot".into(), "mac_send_mail" | "mac_send_message" | "send_sms" | "post_to_x" | "mac_draft_mail" => "正在傳送訊息".into(), "publish_site" => "正在發布".into(), "vault_list" => "正在查看保險庫".into(), "browser_fill_login" | "browser_fill_card" | "browser_fill_contact" => "正在填入保險庫的資料".into(), n if n.starts_with("computer_use") => if on_box { "正在四處點擊".into() } else { "正在你的電腦上工作".into() }, n if n.contains("search") => "正在四處查找".into(), n if n.starts_with("mcp__") => "正在已連結的應用程式中工作".into(), n if n.starts_with("mac_") || n.starts_with("ios_") || n.starts_with("mobile_") => "正在你的電腦上工作".into(), _ => pick(&WORKING), } } /// "11月2日週一 上午9:50" (today: just the time). pub fn date_label(ms: i64) -> String { use chrono::{Datelike, Local, TimeZone, Timelike}; let Some(d) = Local.timestamp_millis_opt(ms).single() else { return String::new() }; let now = Local::now(); let wd = ["週一", "週二", "週三", "週四", "週五", "週六", "週日"][d.weekday().num_days_from_monday() as usize]; let (h, ampm) = match d.hour() { 0 => (12, "上午"), h @ 1..=11 => (h, "上午"), 12 => (12, "下午"), h => (h - 12, "下午"), }; let time = format!("{}{}:{:02}", t(ampm), h, d.minute()); if d.date_naive() == now.date_naive() { time } else if d.year() == now.year() { format!("{}月{}日{} {time}", d.month(), d.day(), t(wd)) } else { format!("{}年{}月{}日 {time}", d.year(), d.month(), d.day()) } } /// An ease with a small overshoot (the reaction pop). fn pop(x: f32) -> f32 { let x = x.clamp(0.0, 1.0); let c1 = 1.70158; let c3 = c1 + 1.0; 1.0 + c3 * (x - 1.0).powi(3) + c1 * (x - 1.0).powi(2) } fn ease_out(x: f32) -> f32 { 1.0 - (1.0 - x.clamp(0.0, 1.0)).powi(3) } /// The indeterminate ring (the original's working arc). pub fn spinner(ui: &mut egui::Ui, size: f32, col: Color32) { let (rect, _) = ui.allocate_exact_size(vec2(size, size), egui::Sense::hover()); let time = ui.input(|i| i.time); let c = rect.center(); let r = size / 2.0 - 1.5; ui.painter().circle_stroke(c, r, Stroke::new(2.0, col.gamma_multiply(0.2))); let start = (time / SPIN_SECS * std::f64::consts::TAU) as f32; let pts: Vec = (0..=16).map(|i| { let a = start + 1.7 * i as f32 / 16.0; c + vec2(a.cos(), a.sin()) * r }).collect(); ui.painter().add(egui::Shape::line(pts, Stroke::new(2.0, col))); ui.ctx().request_repaint(); } /// Text with a light band sweeping across it (the original's sand-shimmer-text). pub fn shimmer(ui: &mut egui::Ui, text: &str, base: Color32, bright: Color32, size: f32) { let time = ui.input(|i| i.time); let chars: Vec = text.chars().collect(); let n = chars.len() as f32; let pos = ((time % SHIMMER_SECS) / SHIMMER_SECS) as f32 * (n + 6.0) - 3.0; let mut job = egui::text::LayoutJob::default(); for (i, ch) in chars.iter().enumerate() { let k = (1.0 - ((i as f32 - pos).abs() / 3.0)).clamp(0.0, 1.0); let col = base.lerp_to_gamma(bright, k); job.append(&ch.to_string(), 0.0, egui::TextFormat { font_id: egui::FontId::proportional(size), color: col, ..Default::default() }); } ui.label(job); ui.ctx().request_repaint(); } impl App { /// One message of the chat. pub(super) fn chat_message(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot, m: &Message, last: bool) { let p = self.pal; let idx = self.msg_index; // Tool results and empty step messages stay out of the chat (⋯ › 顯示步驟 shows them). if m.role == Role::Tool { return; } let has_body = !m.text.trim().is_empty() || !m.attachments.is_empty() || m.tool_calls.iter().any(|c| c.name == "show_card") || !crate::pages::ids_in(&m.tool_calls).is_empty(); let steps_open = self.steps_open.contains(&(bot.id, idx)); if m.role == Role::Assistant && !has_body && !m.summary && !steps_open { return; } // The date, when this message is far from the one before it. if m.at > 0 { let prev = bot.messages[..idx].iter().rev().find(|x| x.at > 0 && x.role != Role::Tool).map(|x| x.at); let new_day = |a: i64, b: i64| { use chrono::TimeZone; let d = |ms| chrono::Local.timestamp_millis_opt(ms).single().map(|x| x.date_naive()); d(a) != d(b) }; if prev.map(|pa| m.at - pa > 30 * 60_000 || new_day(pa, m.at)).unwrap_or(true) { ui.add_space(10.0); ui.vertical_centered(|ui| ui.label(RichText::new(date_label(m.at)).size(12.0).color(p.text3))); ui.add_space(10.0); } } // New messages fade and rise in (240 ms). let alpha = if m.at > self.started_ms { let t0 = *self.appeared.entry((bot.id, idx)).or_insert_with(Instant::now); let e = t0.elapsed().as_secs_f32() / ROW_ENTER; if e < 1.0 { ctx.request_repaint(); } ease_out(e) } else { 1.0 }; ui.scope(|ui| { ui.set_opacity(alpha); ui.add_space((1.0 - alpha) * 8.0); match m.role { Role::User => self.user_message(ui, ctx, bot, m, last, idx), _ if m.summary => { Frame::new().stroke(Stroke::new(1.0, p.border)).corner_radius(14).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { crate::icons::label(ui, crate::icons::Icon::Summary, &t("較早的對話已整理成摘要"), 13.0, p.text2); egui::CollapsingHeader::new(RichText::new(t("展開摘要")).size(12.5).color(p.text3)).id_salt(("summary", bot.id, idx)).show(ui, |ui| { self.markdown(ui, &m.text, p.text2, false); }); }); ui.add_space(10.0); } _ => self.bot_message(ui, ctx, bot, m, last, idx), } }); } fn user_message(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot, m: &Message, last: bool, idx: usize) { let p = self.pal; let streaming = self.streams.contains_key(&bot.id); for a in m.attachments.clone() { ui.with_layout(Layout::right_to_left(Align::Min), |ui| self.file_card(ui, &a)); ui.add_space(6.0); } // Messages that woke the bot (a routine, a trigger, a channel, another bot) carry a tag: // shown as a small line with an icon above the bubble, not inside it. let (tag, body) = wake_tag(&m.text); if let Some((icon, label)) = tag { ui.with_layout(Layout::right_to_left(Align::Min), |ui| { crate::icons::label(ui, icon, &t(label), 12.0, p.text3); }); ui.add_space(2.0); } let shown_text = body.to_string(); if !m.text.trim().is_empty() { let hover_id = egui::Id::unique(("hover-user", bot.id, idx)); let was_hovered = ctx.data(|d| d.get_temp::(hover_id)).unwrap_or(false); let row = ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let fill = self.user_bubble(bot); let text_col = if fill != p.bubble_user { Color32::WHITE } else if p.bubble_user.a() < 0xff { p.text } else { p.on_primary }; let max_w = message_max_width(ui.available_width()); // As wide as the words (up to the column's limit), lines reading left to right. let rich = RichText::new(&shown_text).size(TRANSCRIPT_FONT).line_height(Some(TRANSCRIPT_LINE_HEIGHT)).color(text_col); let text_w = egui::WidgetText::from(rich.clone()).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::FontSelection::Default).size().x; let inner_w = text_w.min(max_w - 24.0).max(8.0); Frame::new().fill(fill).corner_radius(BUBBLE_RADIUS).inner_margin(bubble_margin()).show(ui, |ui| { ui.set_width(inner_w); ui.with_layout(Layout::top_down(Align::Min), |ui| { ui.add(egui::Label::new(rich).wrap()); }); }); // Hover: ↩ and ⋯ to the left of the bubble (kept while the menu is open). let menu_open = self.menu_for == Some((bot.id, idx)) && egui::Popup::is_any_open(ctx); if was_hovered || menu_open { ui.add_space(4.0); if hover_icon(ui, crate::icons::Icon::Reply, p).on_hover_text(t("回覆")).clicked() { self.quoting.insert(bot.id, m.text.clone()); } let more = hover_icon(ui, crate::icons::Icon::More, p).on_hover_text(t("更多訊息操作")); if more.clicked() { self.menu_for = Some((bot.id, idx)); } egui::Popup::menu(&more).show(|ui| { ui.set_min_width(menu_ui::MENU_W); if menu_ui::item(ui, p, crate::icons::Icon::Copy, "複製").clicked() { ctx.copy_text(m.text.clone()); ui.close(); } if menu_ui::item(ui, p, crate::icons::Icon::Reply, "回覆").clicked() { self.quoting.insert(bot.id, m.text.clone()); ui.close(); } }); } }); let hovered = row.response.contains_pointer(); if hovered != was_hovered { ctx.data_mut(|d| d.insert_temp(hover_id, hovered)); ctx.request_repaint(); } } // The bot's reactions on this message. if !m.reactions.is_empty() { ui.with_layout(Layout::right_to_left(Align::Min), |ui| { ui.add_space(6.0); for r in m.reactions.iter().rev() { self.reaction_pill(ui, bot.id, idx, r, false); } }); } // A message that never got an answer can be sent again. if last && !streaming { ui.with_layout(Layout::right_to_left(Align::Min), |ui| { if small_button(ui, "重新傳送", p).clicked() { self.start_reply(ctx, bot.id); } ui.label(RichText::new(t("還沒有回覆")).size(11.5).color(p.text3)); }); } ui.add_space(10.0); } fn bot_message(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot, m: &Message, last: bool, idx: usize) { let p = self.pal; let streaming = last && self.streams.contains_key(&bot.id); // In a group chat, who said it. if let Some(a) = self.state.bot(m.author).filter(|_| m.author != 0) { ui.horizontal(|ui| { ui.add_space(4.0); ui.label(RichText::new(&a.name).size(12.0).color(p.text3)); }); ui.add_space(2.0); } if !m.text.trim().is_empty() { let hover_id = egui::Id::unique(("hover-bot", bot.id, idx)); let was_hovered = ctx.data(|d| d.get_temp::(hover_id)).unwrap_or(false); let row = ui.horizontal_top(|ui| { let max_w = message_max_width(ui.available_width()); let fill = if m.error { p.danger.gamma_multiply(0.12) } else { files_ui::agent_fill(p) }; let col = if m.error { p.danger } else { p.text }; Frame::new().fill(fill).corner_radius(BUBBLE_RADIUS).inner_margin(bubble_margin()).show(ui, |ui| { ui.set_max_width(max_w); ui.with_layout(Layout::top_down(Align::Min), |ui| self.markdown(ui, &m.text, col, streaming)); }); // Hover: 表情回應, 回覆, ⋯ to the right of the bubble (like the original). They stay // while one of their menus is open, so moving onto the menu doesn't close it. let menu_open = self.menu_for == Some((bot.id, idx)) && egui::Popup::is_any_open(ctx); if (was_hovered || menu_open) && !streaming { ui.add_space(2.0); ui.vertical(|ui| { ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let react = hover_icon(ui, crate::icons::Icon::Smile, p).on_hover_text(t("新增表情回應")); egui::Popup::menu(&react).show(|ui| { ui.add_space(2.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; ui.add_space(4.0); for e in QUICK { if ui.add(egui::Button::new(RichText::new(e).size(20.0)).frame(false).min_size(vec2(34.0, 34.0)).corner_radius(10)).clicked() { self.toggle_reaction(bot.id, idx, e); ui.close(); } } }); menu_ui::sep(ui, p); menu_ui::caption(ui, p, "更多表情符號"); ui.add_space(4.0); egui::Grid::new(("more-emoji", bot.id, idx)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { for (i, e) in MORE.iter().enumerate() { if ui.add(egui::Button::new(RichText::new(*e).size(18.0)).frame(false).min_size(vec2(30.0, 30.0)).corner_radius(8)).clicked() { self.toggle_reaction(bot.id, idx, e); ui.close(); } if i % 8 == 7 { ui.end_row(); } } }); }); if hover_icon(ui, crate::icons::Icon::Reply, p).on_hover_text(t("回覆")).clicked() { self.quoting.insert(bot.id, m.text.clone()); } if react.clicked() { self.menu_for = Some((bot.id, idx)); } let more = hover_icon(ui, crate::icons::Icon::More, p).on_hover_text(t("更多訊息操作")); if more.clicked() { self.menu_for = Some((bot.id, idx)); } egui::Popup::menu(&more).show(|ui| { use crate::icons::Icon; ui.set_min_width(menu_ui::MENU_W); if menu_ui::item(ui, p, Icon::Copy, "複製").clicked() { ctx.copy_text(m.text.clone()); ui.close(); } if menu_ui::item(ui, p, Icon::Reply, "回覆").clicked() { self.quoting.insert(bot.id, m.text.clone()); ui.close(); } if last && menu_ui::item(ui, p, Icon::Refresh, "重新產生").clicked() { self.regenerate(ctx, bot.id); ui.close(); } let steps = self.steps_of(bot, idx); if steps > 0 || !m.thinking.is_empty() { let open = self.steps_open.contains(&(bot.id, idx)); let label = if open { t("隱藏步驟") } else { format!("{}({steps})", t("顯示步驟")) }; if menu_ui::item_text(ui, p, Some(Icon::Template), &label).clicked() { if open { self.steps_open.remove(&(bot.id, idx)); } else { self.steps_open.insert((bot.id, idx)); } ui.close(); } } if !m.via.is_empty() { menu_ui::sep(ui, p); menu_ui::caption(ui, p, &tidy_dots(&m.via)); } }); }); }); } }); let hovered = row.response.contains_pointer(); if hovered != was_hovered { ctx.data_mut(|d| d.insert_temp(hover_id, hovered)); ctx.request_repaint(); } } // Steps and thinking, when unfolded from ⋯. if self.steps_open.contains(&(bot.id, idx)) { self.steps_list(ui, bot, idx); } for c in m.tool_calls.iter().filter(|c| c.name == "show_card") { ui.add_space(6.0); card(ui, &c.args, p); } // 頁面 written in this reply: the card (the page as it is now). for id in crate::pages::ids_in(&m.tool_calls) { ui.add_space(8.0); self.page_card(ui, id); } for a in m.attachments.clone() { ui.add_space(6.0); self.file_card(ui, &a); } if !m.reactions.is_empty() { ui.add_space(4.0); ui.horizontal(|ui| { for r in m.reactions.clone() { self.reaction_pill(ui, bot.id, idx, &r, true); } }); } ui.add_space(6.0); } /// How many steps (tool calls) belong to the reply at `idx` and the step messages right /// before it in the same turn. fn steps_of(&self, bot: &Bot, idx: usize) -> usize { self.step_range(bot, idx).map(|i| bot.messages[i].tool_calls.iter().filter(|c| c.name != "show_card").count()).sum() } fn step_range(&self, bot: &Bot, idx: usize) -> std::ops::Range { let mut start = idx; while start > 0 && bot.messages[start - 1].role != Role::User { start -= 1; } start..idx + 1 } fn steps_list(&mut self, ui: &mut egui::Ui, bot: &Bot, idx: usize) { let p = self.pal; Frame::new().stroke(Stroke::new(1.0, p.border)).corner_radius(12).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { ui.set_max_width(message_max_width(ui.available_width())); for i in self.step_range(bot, idx) { let m = &bot.messages[i]; if !m.thinking.is_empty() { egui::CollapsingHeader::new(RichText::new(t("思考過程")).size(12.5).color(p.text3)).id_salt(("think", bot.id, i)).show(ui, |ui| { ui.label(RichText::new(&m.thinking).size(12.5).color(p.text3)); }); } for c in m.tool_calls.iter().filter(|c| c.name != "show_card") { use crate::icons::Icon; let (icon, col) = match c.status { ToolStatus::Waiting => (Icon::Alert, p.accent), ToolStatus::Running => (Icon::Loader, p.text2), ToolStatus::Done => (Icon::CircleCheck, Color32::from_rgb(0x1f, 0xb3, 0x5d)), ToolStatus::Denied => (Icon::Minus, p.text3), ToolStatus::Failed => (Icon::CircleX, p.danger), }; let what: String = crate::remote::tool_label(&c.name).to_string(); let detail: String = if appctl::is_app_tool(&c.name) { appctl::describe(&c.name, &c.args) } else if crate::computer::is_computer_tool(&c.name) { crate::computer::describe(&c.name, &c.args) } else if web::is_web_tool(&c.name) { web::describe(&c.name, &c.args) } else { tools::describe(&c.name, &c.args) }; let result = bot.messages.iter().find(|r| r.role == Role::Tool && r.tool_call_id == c.id).map(|r| r.text.chars().take(4000).collect::()); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); crate::icons::paint(ui.painter(), r, icon, col); ui.label(RichText::new(t(&what)).size(12.5).color(p.text2)); let l = ui.add(egui::Label::new(RichText::new(detail.chars().take(80).collect::()).monospace().size(11.5).color(p.text3)).truncate()); if let Some(r) = result { l.on_hover_text(r); } }); } } }); ui.add_space(4.0); } /// The working row at the end of the chat: ring + shimmering phrase. pub(super) fn working_row(&mut self, ui: &mut egui::Ui, bot: &Bot) { let p = self.pal; let Some(live) = self.streams.get(&bot.id) else { return }; let waiting = self.approvals.contains_key(&bot.id); let last = bot.messages.iter().rev().find(|m| m.role == Role::Assistant); let running = last.and_then(|m| m.tool_calls.iter().rev().find(|c| matches!(c.status, ToolStatus::Running | ToolStatus::Waiting))); // Where it works, by the computer's name (its own computer is 「 的電腦」). let named = running.and_then(|c| tools::args_of(&c.args)["computer"].as_str().map(String::from)); let own = crate::model::own_computer_name(&bot.name); let on_box = running.is_some() && named.as_deref().map(|n| n == own || n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true); let has_text = last.map(|m| !m.text.trim().is_empty()).unwrap_or(false); let seed = live.turn_id.bytes().fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b as u64)); let phrase = live.status.clone().unwrap_or_else(|| { let ph = status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed); match &named { Some(n) if ph == "正在你的電腦上工作" => t("正在「{name}」上工作").replace("{name}", n), _ => t(&ph), } }); ui.add_space(4.0); ui.horizontal(|ui| { ui.add_space(6.0); ui.spacing_mut().item_spacing.x = 8.0; spinner(ui, 16.0, if waiting { p.accent } else { p.text2 }); shimmer(ui, &phrase, p.text3, p.text, 14.0); }); ui.add_space(10.0); } fn toggle_reaction(&mut self, bot_id: u64, idx: usize, e: &str) { if let Some(b) = self.state.bot_mut(bot_id) { if let Some(m) = b.messages.get_mut(idx) { if let Some(i) = m.reactions.iter().position(|x| x == e) { m.reactions.remove(i); } else { m.reactions.push(e.to_string()); self.popped.insert((bot_id, idx, e.to_string()), Instant::now()); } } } self.mark(); } /// One reaction under a message; new ones pop in. The person can take theirs back. fn reaction_pill(&mut self, ui: &mut egui::Ui, bot_id: u64, idx: usize, e: &str, mine: bool) { let p = self.pal; let scale = match self.popped.get(&(bot_id, idx, e.to_string())) { Some(t0) if t0.elapsed().as_secs_f32() < REACTION_POP => { ui.ctx().request_repaint(); pop(t0.elapsed().as_secs_f32() / REACTION_POP) } _ => 1.0, }; let (rect, r) = ui.allocate_exact_size(vec2(34.0, 24.0), egui::Sense::click()); let fill = files_ui::agent_fill(p); ui.painter().rect_filled(egui::Rect::from_center_size(rect.center(), rect.size() * scale.max(0.01)), 12, if r.hovered() && mine { p.hover } else { fill }); ui.painter().rect_stroke(egui::Rect::from_center_size(rect.center(), rect.size() * scale.max(0.01)), 12, Stroke::new(1.0, p.border), egui::StrokeKind::Inside); ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, e, egui::FontId::proportional(14.0 * scale.max(0.01)), p.text); if mine { if r.on_hover_text(t("移除這個回應")).clicked() { self.toggle_reaction(bot_id, idx, e); } } else { r.on_hover_text(t("Bot 的表情回應")); } } } /// "[例行任務]…" → (icon, label) and the words after the tag. pub fn wake_tag(text: &str) -> (Option<(crate::icons::Icon, &'static str)>, &str) { use crate::icons::Icon; // (The emoji forms are what older chats were saved with.) for (tag, icon, label) in [("[例行任務]", Icon::Alarm, "例行任務"), ("[觸發]", Icon::Zap, "觸發"), ("[訊息管道]", Icon::Message, "訊息管道"), ("[來自 Bot]", Icon::Bot, "來自其他 Bot"), ("⏰ ", Icon::Alarm, "例行任務"), ("⚡ ", Icon::Zap, "觸發"), ("💬 ", Icon::Message, "訊息管道"), ("📨 ", Icon::Bot, "來自其他 Bot")] { if let Some(rest) = text.strip_prefix(tag) { return (Some((icon, label)), rest); } } (None, text) } /// A 26 px quiet icon button (the hover actions). fn hover_icon(ui: &mut egui::Ui, icon: crate::icons::Icon, p: Palette) -> egui::Response { crate::icons::button(ui, icon, 26.0, p.text3, p.hover) } #[cfg(test)] mod tests { use super::*; #[test] fn phrases_follow_what_it_does() { assert_eq!(status_phrase(Some("read_file"), false, false, false, 0), "正在讀取檔案"); assert_eq!(status_phrase(Some("computer_use_click"), true, false, false, 0), "正在四處點擊"); assert_eq!(status_phrase(Some("computer_use_click"), false, false, false, 0), "正在你的電腦上工作"); assert_eq!(status_phrase(None, false, true, false, 0), "需要你的輸入"); assert!(THINKING.contains(&status_phrase(None, false, false, false, 7).as_str())); assert!(WORKING.contains(&status_phrase(None, false, false, true, 7).as_str())); assert!((pop(1.0) - 1.0).abs() < 1e-4 && pop(0.6) > 1.0); } }