AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
// Attachments in the window: the chips above the message box, the + button, dropping files on
|
|
|
|
|
|
// the window, pasting a picture with ⌘V, and the thumbnails in sent messages.
|
|
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use crate::attach::{self, Attachment};
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
pub struct AttachUi {
|
|
|
|
|
|
pub pending: HashMap<u64, Vec<Attachment>>,
|
|
|
|
|
|
picked: Option<Receiver<(u64, Vec<PathBuf>)>>,
|
2026-11-04 11:30:32 +00:00
|
|
|
|
pub(super) thumbs: HashMap<String, Option<egui::TextureHandle>>,
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
pub error: Option<String>,
|
|
|
|
|
|
voice: Option<(u64, crate::voice::Recording)>,
|
|
|
|
|
|
voice_rx: Option<Receiver<(u64, Result<String, String>)>>,
|
|
|
|
|
|
/// The recording is a meeting (longer; becomes a transcript to summarize).
|
|
|
|
|
|
pub meeting: bool,
|
|
|
|
|
|
/// 通話模式: the bot being talked to, and where the call is.
|
|
|
|
|
|
pub call: Option<(u64, CallPhase)>,
|
|
|
|
|
|
call_rec: Option<crate::voice::Recording>,
|
|
|
|
|
|
call_rx: Option<Receiver<Result<String, String>>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
|
|
|
|
|
pub enum CallPhase {
|
|
|
|
|
|
Listening,
|
|
|
|
|
|
Transcribing,
|
|
|
|
|
|
Thinking,
|
|
|
|
|
|
Speaking,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl CallPhase {
|
|
|
|
|
|
pub fn label(self) -> &'static str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
CallPhase::Listening => "正在聽…",
|
|
|
|
|
|
CallPhase::Transcribing => "聽懂中…",
|
|
|
|
|
|
CallPhase::Thinking => "想一下…",
|
|
|
|
|
|
CallPhase::Speaking => "正在說…",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl App {
|
|
|
|
|
|
fn add_attachment_paths(&mut self, bot_id: u64, paths: Vec<PathBuf>) {
|
|
|
|
|
|
for path in paths {
|
|
|
|
|
|
match attach::store(bot_id, &path) {
|
|
|
|
|
|
Ok(a) => self.att.pending.entry(bot_id).or_default().push(a),
|
|
|
|
|
|
Err(e) => self.att.error = Some(e),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Files dropped on the window, the picker's answer, and ⌘V with a picture on the clipboard.
|
|
|
|
|
|
pub(super) fn attachments_poll(&mut self, ctx: &egui::Context, bot_id: u64, composer_focused: bool) {
|
|
|
|
|
|
let dropped: Vec<PathBuf> = ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_path_buf()).filter(|p| !p.as_os_str().is_empty()).collect());
|
|
|
|
|
|
if !dropped.is_empty() {
|
|
|
|
|
|
self.add_attachment_paths(bot_id, dropped);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(rx) = &self.att.picked {
|
|
|
|
|
|
if let Ok((id, paths)) = rx.try_recv() {
|
|
|
|
|
|
self.att.picked = None;
|
|
|
|
|
|
self.add_attachment_paths(id, paths);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
// ⌘V with no text on the clipboard: maybe a picture. The window answers the paste keys
|
|
|
|
|
|
// itself and hands over the picture (no V key arrives for ⌘V); a V key with ⌘ that does
|
|
|
|
|
|
// arrive (⌘⇧V) looks at the clipboard as before.
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
let pasted_text = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(t) if !t.is_empty())));
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
let pasted_image = ctx.input(|i| i.events.iter().find_map(|e| if let egui::Event::PasteImage(img) = e { Some(img.clone()) } else { None }));
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
let cmd_v = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command)));
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
if !composer_focused || self.demo {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(img) = pasted_image {
|
|
|
|
|
|
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
|
|
|
|
|
|
match attach::store_pasted(bot_id, img.size, rgba) {
|
|
|
|
|
|
Ok(a) => self.att.pending.entry(bot_id).or_default().push(a),
|
|
|
|
|
|
Err(e) => self.att.error = Some(e),
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if cmd_v && !pasted_text {
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
if let Some(a) = attach::paste_image(bot_id) {
|
|
|
|
|
|
self.att.pending.entry(bot_id).or_default().push(a);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn pick_attachments(&mut self, ctx: &egui::Context, bot_id: u64) {
|
|
|
|
|
|
let (tx, rx) = channel();
|
|
|
|
|
|
self.att.picked = Some(rx);
|
|
|
|
|
|
let c = ctx.clone();
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let paths = attach::pick_files();
|
|
|
|
|
|
let _ = tx.send((bot_id, paths));
|
|
|
|
|
|
c.request_repaint();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// "Drop to attach" while files hover over the window.
|
|
|
|
|
|
pub(super) fn drop_overlay(&self, ctx: &egui::Context) {
|
|
|
|
|
|
if !ctx.input(|i| !i.raw.hovered_files.is_empty()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let p = self.pal;
|
|
|
|
|
|
let screen = ctx.content_rect();
|
|
|
|
|
|
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("drop-overlay")));
|
|
|
|
|
|
painter.rect_filled(screen, 0, p.pane.gamma_multiply(0.85));
|
|
|
|
|
|
let inner = screen.shrink(24.0);
|
|
|
|
|
|
painter.rect_stroke(inner, 18, Stroke::new(2.0, p.accent), egui::StrokeKind::Inside);
|
|
|
|
|
|
painter.text(inner.center(), egui::Align2::CENTER_CENTER, "放開以附加檔案", egui::FontId::proportional(FONT_XL), p.accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn thumb(&mut self, ctx: &egui::Context, path: &str) -> Option<egui::TextureHandle> {
|
|
|
|
|
|
if let Some(t) = self.att.thumbs.get(path) {
|
|
|
|
|
|
return t.clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
let t = image::open(path).ok().map(|img| {
|
|
|
|
|
|
let img = img.thumbnail(240, 240).to_rgba8();
|
|
|
|
|
|
let size = [img.width() as usize, img.height() as usize];
|
|
|
|
|
|
ctx.load_texture(format!("thumb-{path}"), egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR)
|
|
|
|
|
|
});
|
|
|
|
|
|
self.att.thumbs.insert(path.to_string(), t.clone());
|
|
|
|
|
|
t
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// One attachment: a picture thumbnail, or a file chip. Returns true when its × was clicked.
|
|
|
|
|
|
pub(super) fn attachment_chip(&mut self, ui: &mut egui::Ui, a: &Attachment, removable: bool) -> bool {
|
|
|
|
|
|
let p = self.pal;
|
|
|
|
|
|
let ctx = ui.ctx().clone();
|
|
|
|
|
|
let mut remove = false;
|
|
|
|
|
|
let thumb = if a.image { self.thumb(&ctx, &a.path) } else { None };
|
|
|
|
|
|
let frame = Frame::new().fill(p.hover).corner_radius(10).inner_margin(if thumb.is_some() { Margin::same(3) } else { Margin::symmetric(10, 6) });
|
|
|
|
|
|
let r = frame
|
|
|
|
|
|
.show(ui, |ui| {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
if let Some(t) = &thumb {
|
|
|
|
|
|
let s = t.size_vec2();
|
|
|
|
|
|
let k = (64.0 / s.y).min(120.0 / s.x);
|
|
|
|
|
|
ui.add(egui::Image::new((t.id(), s * k)).corner_radius(8));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let (rect, _) = ui.allocate_exact_size(vec2(14.0, 16.0), egui::Sense::hover());
|
|
|
|
|
|
let r = rect.shrink2(vec2(1.5, 1.0));
|
|
|
|
|
|
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.3, p.text2), egui::StrokeKind::Middle);
|
|
|
|
|
|
ui.painter().line_segment([r.left_top() + vec2(3.0, 5.0), r.right_top() + vec2(-3.0, 5.0)], Stroke::new(1.0, p.text3));
|
|
|
|
|
|
ui.painter().line_segment([r.left_top() + vec2(3.0, 8.5), r.right_top() + vec2(-3.0, 8.5)], Stroke::new(1.0, p.text3));
|
|
|
|
|
|
ui.vertical(|ui| {
|
|
|
|
|
|
ui.spacing_mut().item_spacing.y = 0.0;
|
|
|
|
|
|
let name: String = a.name.chars().take(28).collect();
|
|
|
|
|
|
ui.label(RichText::new(name).size(FONT_XS).color(p.text));
|
|
|
|
|
|
ui.label(RichText::new(attach::human(a.bytes)).size(10.5).color(p.text3));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if removable && ui.add(egui::Button::new(RichText::new("×").size(13.0).color(p.text3)).frame(false)).on_hover_text(t("移除")).clicked() {
|
|
|
|
|
|
remove = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.response;
|
|
|
|
|
|
if !removable && r.interact(egui::Sense::click()).on_hover_text(t("打開")).clicked() {
|
|
|
|
|
|
crate::preview::open_with_system(&a.path);
|
|
|
|
|
|
}
|
|
|
|
|
|
remove
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The chips above the message box.
|
|
|
|
|
|
pub(super) fn pending_chips(&mut self, ui: &mut egui::Ui, bot_id: u64) {
|
|
|
|
|
|
let list = self.att.pending.get(&bot_id).cloned().unwrap_or_default();
|
|
|
|
|
|
if list.is_empty() && self.att.error.is_none() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut remove = None;
|
|
|
|
|
|
ui.horizontal_wrapped(|ui| {
|
|
|
|
|
|
for (i, a) in list.iter().enumerate() {
|
|
|
|
|
|
if self.attachment_chip(ui, a, true) {
|
|
|
|
|
|
remove = Some(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if let Some(i) = remove {
|
|
|
|
|
|
if let Some(v) = self.att.pending.get_mut(&bot_id) {
|
|
|
|
|
|
let a = v.remove(i);
|
|
|
|
|
|
let _ = std::fs::remove_file(&a.path);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(e) = self.att.error.clone() {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(RichText::new(e).size(FONT_XS).color(self.pal.danger));
|
|
|
|
|
|
if ui.add(egui::Button::new(RichText::new(t("知道了")).size(FONT_XS)).frame(false)).clicked() {
|
|
|
|
|
|
self.att.error = None;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.add_space(4.0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Take this bot's pending attachments for a message (copying them onto its own computer when
|
|
|
|
|
|
/// it's running).
|
|
|
|
|
|
pub(super) fn take_attachments(&mut self, bot_id: u64) -> Vec<Attachment> {
|
|
|
|
|
|
let mut list = self.att.pending.remove(&bot_id).unwrap_or_default();
|
|
|
|
|
|
for a in list.iter_mut() {
|
|
|
|
|
|
if let Ok(dst) = crate::cloudbox::copy_in(&a.path, bot_id, &a.name) {
|
|
|
|
|
|
a.on_box = dst;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
list
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn has_pending_attachments(&self, bot_id: u64) -> bool {
|
|
|
|
|
|
self.att.pending.get(&bot_id).map(|v| !v.is_empty()).unwrap_or(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 語音輸入 ----------
|
|
|
|
|
|
|
|
|
|
|
|
impl App {
|
|
|
|
|
|
pub(super) fn voice_recording(&self, bot_id: u64) -> Option<Instant> {
|
|
|
|
|
|
self.att.voice.as_ref().filter(|(id, _)| *id == bot_id).map(|(_, r)| r.started)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn voice_busy(&self) -> bool {
|
|
|
|
|
|
self.att.voice_rx.is_some()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The mic button: start, or stop and turn the recording into text.
|
|
|
|
|
|
pub(super) fn voice_toggle(&mut self, ctx: &egui::Context, bot_id: u64) {
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
if self.voice_stop(ctx) {
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
crate::voice::stop_speaking();
|
|
|
|
|
|
match crate::voice::start() {
|
|
|
|
|
|
Ok(r) => self.att.voice = Some((bot_id, r)),
|
|
|
|
|
|
Err(e) => self.att.error = Some(e),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
/// Stop the recording that is running and turn it into text (false: none was running).
|
|
|
|
|
|
fn voice_stop(&mut self, ctx: &egui::Context) -> bool {
|
|
|
|
|
|
let Some((id, rec)) = self.att.voice.take() else { return false };
|
|
|
|
|
|
let lang = self.state.bot(id).map(|b| b.voice_lang.clone()).unwrap_or_default();
|
|
|
|
|
|
let ps = self.state.provider(ProviderKind::OpenAi);
|
|
|
|
|
|
let base = providers::resolve_base(ProviderKind::OpenAi, &ps.base_url);
|
|
|
|
|
|
let key = providers::resolve_key(ProviderKind::OpenAi, &ps.api_key);
|
|
|
|
|
|
let (tx, rx) = channel();
|
|
|
|
|
|
self.att.voice_rx = Some(rx);
|
|
|
|
|
|
let c = ctx.clone();
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let r = rec.finish().and_then(|f| {
|
|
|
|
|
|
let t = crate::voice::transcribe_lang(&base, &key, &f, &lang);
|
|
|
|
|
|
let _ = std::fs::remove_file(&f);
|
|
|
|
|
|
t
|
|
|
|
|
|
});
|
|
|
|
|
|
let _ = tx.send((id, r));
|
|
|
|
|
|
c.request_repaint();
|
|
|
|
|
|
});
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
/// 會議記錄: a long recording that becomes a transcript for the bot to summarize.
|
|
|
|
|
|
pub(super) fn start_meeting(&mut self, ctx: &egui::Context, bot_id: u64) {
|
|
|
|
|
|
if self.att.voice.is_some() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.att.meeting = true;
|
|
|
|
|
|
self.voice_toggle(ctx, bot_id);
|
|
|
|
|
|
if self.att.voice.is_none() {
|
|
|
|
|
|
self.att.meeting = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn meeting_on(&self) -> bool {
|
|
|
|
|
|
self.att.meeting && self.att.voice.is_some()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Put the text where the person was typing.
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
pub(super) fn voice_poll(&mut self, ctx: &egui::Context) {
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
let max = if self.att.meeting { crate::voice::MEETING_MAX_SECS } else { crate::voice::MAX_SECS };
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
if self.att.voice.as_ref().is_some_and(|(_, r)| r.started.elapsed().as_secs() >= max) {
|
|
|
|
|
|
// What was recorded up to the limit is kept and turned into text like any
|
|
|
|
|
|
// recording (it was thrown away: two hours of a meeting, gone at the last second).
|
|
|
|
|
|
self.voice_stop(ctx);
|
|
|
|
|
|
self.att.error = Some(format!("錄音最長 {},已經停止;錄到的部分正在轉成文字。", crate::voice::length_words(max)));
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
let Some(rx) = &self.att.voice_rx else { return };
|
|
|
|
|
|
if let Ok((id, r)) = rx.try_recv() {
|
|
|
|
|
|
self.att.voice_rx = None;
|
|
|
|
|
|
let meeting = std::mem::take(&mut self.att.meeting);
|
|
|
|
|
|
match r {
|
|
|
|
|
|
Ok(t) if t.is_empty() => self.att.error = Some("沒有聽到說話。".into()),
|
|
|
|
|
|
Ok(t) if meeting => {
|
|
|
|
|
|
// The transcript goes with the message as a file; the bot is asked to sum it up.
|
|
|
|
|
|
let dir = std::env::temp_dir().join("anybot-meetings");
|
|
|
|
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
|
|
|
|
let file = dir.join(format!("會議記錄 {}.txt", chrono::Local::now().format("%Y-%m-%d %H%M")));
|
|
|
|
|
|
match std::fs::write(&file, &t).map_err(|e| e.to_string()).and_then(|_| crate::attach::store(id, &file)) {
|
|
|
|
|
|
Ok(a) => {
|
|
|
|
|
|
self.att.pending.entry(id).or_default().push(a);
|
|
|
|
|
|
self.drafts.insert(id, "這是剛才會議的文字記錄,請整理:重點、做了哪些決定、待辦事項(誰、做什麼、什麼時候)。".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => self.att.error = Some(format!("存不了會議記錄:{e}")),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(t) => {
|
|
|
|
|
|
let d = self.drafts.entry(id).or_default();
|
|
|
|
|
|
if !d.is_empty() && !d.ends_with([' ', '\n']) {
|
|
|
|
|
|
d.push(' ');
|
|
|
|
|
|
}
|
|
|
|
|
|
d.push_str(&t);
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => self.att.error = Some(e),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 通話模式 ----------
|
|
|
|
|
|
|
|
|
|
|
|
impl App {
|
|
|
|
|
|
pub(super) fn call_start(&mut self, bot_id: u64) {
|
|
|
|
|
|
crate::voice::stop_speaking();
|
|
|
|
|
|
self.att.call = Some((bot_id, CallPhase::Listening));
|
|
|
|
|
|
self.att.call_rec = None;
|
|
|
|
|
|
self.att.call_rx = None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn call_end(&mut self) {
|
|
|
|
|
|
if let Some(r) = self.att.call_rec.take() {
|
|
|
|
|
|
r.cancel();
|
|
|
|
|
|
}
|
|
|
|
|
|
crate::voice::stop_speaking();
|
|
|
|
|
|
self.att.call = None;
|
|
|
|
|
|
self.att.call_rx = None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn call_state(&self) -> Option<(u64, CallPhase)> {
|
|
|
|
|
|
self.att.call
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Listen → understand → the bot answers → it's read aloud → listen again.
|
|
|
|
|
|
pub(super) fn call_poll(&mut self, ctx: &egui::Context) {
|
|
|
|
|
|
let Some((bot_id, phase)) = self.att.call else { return };
|
|
|
|
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(150));
|
|
|
|
|
|
match phase {
|
|
|
|
|
|
CallPhase::Listening => {
|
|
|
|
|
|
if self.att.call_rec.is_none() {
|
|
|
|
|
|
match crate::voice::listen() {
|
|
|
|
|
|
Ok(r) => self.att.call_rec = Some(r),
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
self.att.error = Some(e);
|
|
|
|
|
|
self.call_end();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.att.call_rec.as_mut().map(|r| r.done()).unwrap_or(false) {
|
|
|
|
|
|
let rec = self.att.call_rec.take().unwrap();
|
|
|
|
|
|
let lang = self.state.bot(bot_id).map(|b| b.voice_lang.clone()).unwrap_or_default();
|
|
|
|
|
|
let ps = self.state.provider(ProviderKind::OpenAi);
|
|
|
|
|
|
let base = providers::resolve_base(ProviderKind::OpenAi, &ps.base_url);
|
|
|
|
|
|
let key = providers::resolve_key(ProviderKind::OpenAi, &ps.api_key);
|
|
|
|
|
|
let (tx, rx) = channel();
|
|
|
|
|
|
self.att.call_rx = Some(rx);
|
|
|
|
|
|
let c = ctx.clone();
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let r = rec.finish().and_then(|f| {
|
|
|
|
|
|
let t = crate::voice::transcribe_lang(&base, &key, &f, &lang);
|
|
|
|
|
|
let _ = std::fs::remove_file(&f);
|
|
|
|
|
|
t
|
|
|
|
|
|
});
|
|
|
|
|
|
let _ = tx.send(r);
|
|
|
|
|
|
c.request_repaint();
|
|
|
|
|
|
});
|
|
|
|
|
|
self.att.call = Some((bot_id, CallPhase::Transcribing));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
CallPhase::Transcribing => {
|
|
|
|
|
|
let Some(rx) = &self.att.call_rx else { return };
|
|
|
|
|
|
if let Ok(r) = rx.try_recv() {
|
|
|
|
|
|
self.att.call_rx = None;
|
|
|
|
|
|
match r {
|
|
|
|
|
|
Ok(t) if !t.trim().is_empty() => {
|
|
|
|
|
|
self.drafts.insert(bot_id, t);
|
|
|
|
|
|
self.send(ctx, bot_id);
|
|
|
|
|
|
self.att.call = Some((bot_id, CallPhase::Thinking));
|
|
|
|
|
|
}
|
|
|
|
|
|
// Nothing said: keep listening; a real error ends the call.
|
|
|
|
|
|
Err(e) if !e.contains("沒有聽到") => {
|
|
|
|
|
|
self.att.error = Some(e);
|
|
|
|
|
|
self.call_end();
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => self.att.call = Some((bot_id, CallPhase::Listening)),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
CallPhase::Thinking => {
|
|
|
|
|
|
if !self.streams.contains_key(&bot_id) && !self.approvals.contains_key(&bot_id) {
|
|
|
|
|
|
let answer = self.state.bot(bot_id).and_then(|b| b.messages.iter().rev().find(|m| m.role == Role::Assistant && !m.text.trim().is_empty())).map(|m| m.text.clone());
|
|
|
|
|
|
// Answers are read here, not by the 朗讀 switch (no double reading).
|
|
|
|
|
|
if let Some(a) = answer {
|
|
|
|
|
|
let (v, r) = self.state.bot(bot_id).map(|b| (b.voice.clone(), b.voice_rate)).unwrap_or_default();
|
|
|
|
|
|
crate::voice::speak_as(&a, &v, if r > 0.0 { r } else { 1.0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
self.att.call = Some((bot_id, CallPhase::Speaking));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
CallPhase::Speaking => {
|
|
|
|
|
|
if !crate::voice::speaking() {
|
|
|
|
|
|
self.att.call = Some((bot_id, CallPhase::Listening));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|