542 lines
28 KiB
Rust
542 lines
28 KiB
Rust
|
|
// 對話內容, 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");
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
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)));
|
|||
|
|
let on_box = running.map(|c| tools::args_of(&c.args)["computer"].as_str().map(|n| n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true)).unwrap_or(false);
|
|||
|
|
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(|| t(&status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed)));
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|