AnyBot/src/app/chat_ui.rs

556 lines
29 KiB
Rust
Raw Normal View History

// 對話內容, 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<egui::Pos2> = (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<char> = 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::<bool>(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::<bool>(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<usize> {
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::<String>());
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::<String>()).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 「<name> 的電腦」).
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);
}
}