AnyBot/src/app/chat_ui.rs

556 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 對話內容, 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);
}
}