Texture caches capped; big previews decode off the UI thread; helper processes reaped

This commit is contained in:
王性驊 2026-11-05 15:48:22 +08:00
parent 21b917b0eb
commit 7391e3a101
7 changed files with 116 additions and 16 deletions

View File

@ -943,6 +943,9 @@ impl App {
for id in std::mem::take(&mut self.group_next) { for id in std::mem::take(&mut self.group_next) {
if !self.streams.contains_key(&id) { if !self.streams.contains_key(&id) {
self.start_reply(ctx, id); self.start_reply(ctx, id);
} else {
// Still busy: keep the turn queued instead of dropping it with the take().
self.group_next.push(id);
} }
} }
for (id, texts) in std::mem::take(&mut self.pending_turns) { for (id, texts) in std::mem::take(&mut self.pending_turns) {
@ -951,9 +954,15 @@ impl App {
b.messages.push(Message::user(&t)); b.messages.push(Message::user(&t));
} }
} }
if self.streams.contains_key(&id) {
// A turn just settled in the same tick we queued this one; starting now would
// replace the running Live and let two agent threads write one chat. Retry idle.
self.group_next.push(id);
} else {
self.start_reply(ctx, id); self.start_reply(ctx, id);
} }
} }
}
fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) { fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) {
let out = if call.name == "list_computers" { let out = if call.name == "list_computers" {
@ -1239,9 +1248,11 @@ impl App {
StreamEvent::Steered(m) => b.messages.push(m), StreamEvent::Steered(m) => b.messages.push(m),
StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)), StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)),
StreamEvent::ToolResult(m) => b.messages.push(m), StreamEvent::ToolResult(m) => b.messages.push(m),
StreamEvent::Compacted(n, summary) => { StreamEvent::Compacted(n, summary, boundary) => {
let n = n.min(b.messages.len()); // The model's history can be shorter than the window's chat (group chats
b.messages.splice(..n, [summary]); // filter); land the cut at the real first-kept message, not the raw index.
let cut = b.messages.iter().position(|m| m.at == boundary && m.role == Role::User).unwrap_or(n.min(b.messages.len()));
b.messages.splice(..cut, [summary]);
} }
StreamEvent::ToolStatus(call_id, st) => { StreamEvent::ToolStatus(call_id, st) => {
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) { if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
@ -2262,7 +2273,7 @@ impl App {
crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3); crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3);
} else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() { } else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() {
if !crate::axread::trusted(true) { if !crate::axread::trusted(true) {
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn(); let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
} }
} }
return; return;
@ -3238,7 +3249,8 @@ impl App {
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
} }
let secs = t0.elapsed().as_secs(); let secs = t0.elapsed().as_secs();
let msgs = app.state.bot(id).map(|b| b.messages[start..].to_vec()).unwrap_or_default(); // The chat can shrink (compaction) between the snapshot and now: slice safely.
let msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default();
let clip = |s: &str, n: usize| -> String { let clip = |s: &str, n: usize| -> String {
let one = s.replace('\n', " ⏎ "); let one = s.replace('\n', " ⏎ ");
if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::<String>()) } else { one } if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::<String>()) } else { one }

View File

@ -96,6 +96,18 @@ fn start_job(kind: &str, path: &str) -> Option<Arc<Mutex<PageJob>>> {
g.done = true; g.done = true;
}); });
} }
"image" => {
// Decoding (a 6000×4000 photo, a big SVG) on the UI thread froze the window;
// the viewer shows a spinner until this thread is done.
std::thread::spawn(move || {
let img = preview::decode_image(&path);
let mut g = j.lock().unwrap();
if let Some(img) = img {
g.ready.push(img);
}
g.done = true;
});
}
"video" => { "video" => {
std::thread::spawn(move || { std::thread::spawn(move || {
let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms())); let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms()));
@ -258,9 +270,31 @@ impl App {
}); });
} }
Preview::Image => { Preview::Image => {
// The picture is decoded on a worker thread (start_job "image").
if fv.image.is_none() { if fv.image.is_none() {
if let Some(job) = fv.job.clone() {
let (imgs, done) = {
let mut g = job.lock().unwrap();
(std::mem::take(&mut g.ready), g.done)
};
match imgs.into_iter().next() {
Some(img) => fv.image = Some(texture(ui.ctx(), "file-view", &img)),
None => {
ui.add_space(40.0);
ui.vertical_centered(|ui| {
if done {
ui.label(RichText::new(t("這張圖片打不開")).color(p.text3));
} else {
ui.spinner();
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
}
});
}
}
} else {
fv.image = load_texture(ui.ctx(), &fv.path); fv.image = load_texture(ui.ctx(), &fv.path);
} }
}
match &fv.image { match &fv.image {
Some(tex) => { Some(tex) => {
let avail = vec2(ui.available_width() - 24.0, body_h - 24.0); let avail = vec2(ui.available_width() - 24.0, body_h - 24.0);

View File

@ -777,6 +777,12 @@ impl App {
let dark = p.pane != LIGHT.pane; let dark = p.pane != LIGHT.pane;
let key = key_of(src, dark); let key = key_of(src, dark);
if !self.md.diagrams.contains_key(&key) { if !self.md.diagrams.contains_key(&key) {
// Keep a long chat from growing forever: every diagram is a GPU texture. Past a
// roomy cap, drop the whole cache — the diagrams shown again redraw on a worker
// thread (a brief spinner beats slowly eating hundreds of MBs).
if self.md.diagrams.len() >= 64 {
self.md.diagrams.clear();
}
let slot = Arc::new(Mutex::new(None)); let slot = Arc::new(Mutex::new(None));
let (s2, src2) = (slot.clone(), src.to_string()); let (s2, src2) = (slot.clone(), src.to_string());
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();

View File

@ -587,10 +587,13 @@ impl App {
VaultDialog::Import(mut st) => { VaultDialog::Import(mut st) => {
if self.vault_import(ctx, &mut st) { if self.vault_import(ctx, &mut st) {
next = Some(VaultDialog::Import(st)); next = Some(VaultDialog::Import(st));
} } else {
// Only when it closed: marking saved every frame reloaded the whole
// vault list on every repaint while the import dialog was open.
saved = true; saved = true;
} }
} }
}
self.vault.dialog = next; self.vault.dialog = next;
if saved { if saved {
self.vault_refresh(); self.vault_refresh();

View File

@ -250,13 +250,19 @@ fn rasterize(icon: Icon, px: u32) -> Option<egui::ColorImage> {
pub fn paint(p: &Painter, rect: Rect, icon: Icon, col: Color32) { pub fn paint(p: &Painter, rect: Rect, icon: Icon, col: Color32) {
let ppp = p.ctx().pixels_per_point(); let ppp = p.ctx().pixels_per_point();
let side = rect.width().min(rect.height()); let side = rect.width().min(rect.height());
let px = (side * ppp).round().max(8.0) as u32; // Quantised size (2 px steps, capped): a resized window used to create a new texture for
// every pixel width, so the cache grew without limit while the window was dragged.
let px = ((side * ppp).round().max(8.0) as u32 / 2 * 2).clamp(8, 512);
let tex = { let tex = {
let mut c = cache().lock().unwrap(); let mut c = cache().lock().unwrap();
match c.get(&(icon, px)) { match c.get(&(icon, px)) {
Some(t) => t.clone(), Some(t) => t.clone(),
None => { None => {
let Some(img) = rasterize(icon, px) else { return }; let Some(img) = rasterize(icon, px) else { return };
// Roomy hard cap: past it, start the cache fresh (textures reload on demand).
if c.len() >= 1500 {
c.clear();
}
let t = p.ctx().load_texture(format!("icon-{icon:?}-{px}"), img, egui::TextureOptions::LINEAR); let t = p.ctx().load_texture(format!("icon-{icon:?}-{px}"), img, egui::TextureOptions::LINEAR);
c.insert((icon, px), t.clone()); c.insert((icon, px), t.clone());
t t

View File

@ -90,6 +90,7 @@ pub fn screensaver_active() -> bool {
std::thread::sleep(std::time::Duration::from_millis(20)); std::thread::sleep(std::time::Duration::from_millis(20));
} }
let _ = c.kill(); let _ = c.kill();
let _ = c.wait();
false false
} }
@ -116,10 +117,10 @@ pub fn banner(title: &str, body: &str) {
if !cfg!(target_os = "macos") { if !cfg!(target_os = "macos") {
// The desktop's notification service (GNOME, KDE, XFCE…) through notify-send. // The desktop's notification service (GNOME, KDE, XFCE…) through notify-send.
let body: String = body.chars().take(180).collect(); let body: String = body.chars().take(180).collect();
let _ = Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn(); let _ = crate::computer::spawn_reaped(&mut Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()));
return; return;
} }
let _ = Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn(); let _ = crate::computer::spawn_reaped(&mut Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()));
} }
#[cfg(test)] #[cfg(test)]

View File

@ -114,7 +114,8 @@ pub fn system_open(target: impl AsRef<std::ffi::OsStr>) -> std::process::Command
} }
pub fn open_with_system(target: &str) { pub fn open_with_system(target: &str) {
if let Err(e) = system_open(target).spawn() { let mut c = system_open(target);
if let Err(e) = crate::computer::spawn_reaped(&mut c) {
eprintln!("anybot: couldn't open {target}: {e}"); eprintln!("anybot: couldn't open {target}: {e}");
} }
} }
@ -138,7 +139,13 @@ fn clip(t: String) -> String {
} }
fn read_text(path: &Path) -> Result<String, String> { fn read_text(path: &Path) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|e| format!("讀不到檔案:{e}"))?; use std::io::Read;
// Read only the head of the file: a preview never shows more than clip() keeps (200 K
// chars), and reading a multi-GB log whole on the UI thread froze the whole window.
const HEAD: u64 = 4 << 20;
let mut f = std::fs::File::open(path).map_err(|e| format!("讀不到檔案:{e}"))?;
let mut bytes = Vec::new();
f.by_ref().take(HEAD).read_to_end(&mut bytes).map_err(|e| format!("讀不到檔案:{e}"))?;
if bytes.iter().take(8000).any(|b| *b == 0) { if bytes.iter().take(8000).any(|b| *b == 0) {
return Err("這是二進位檔案,沒辦法當文字顯示。".into()); return Err("這是二進位檔案,沒辦法當文字顯示。".into());
} }
@ -456,15 +463,36 @@ fn md_table(rows: &[Vec<String>]) -> String {
s s
} }
/// Find `<tag` followed by `>` or a space, ASCII case-insensitive, on the original string.
/// (Lowercasing the whole page first shifts byte offsets — "İ" becomes 3 bytes when lowered —
/// and cutting the original at those offsets panics.)
fn find_open_tag(s: &str, tag: &str) -> Option<usize> {
let (b, t) = (s.as_bytes(), tag.as_bytes());
(0..b.len()).find(|&i| {
b[i] == b'<'
&& b.len() >= i + 1 + t.len()
&& b[i + 1..].iter().zip(t).all(|(a, c)| a.eq_ignore_ascii_case(c))
&& matches!(b.get(i + 1 + t.len()), Some(c) if *c == b'>' || c.is_ascii_whitespace())
})
}
/// `needle` (ASCII) in `hay`, ASCII case-insensitive, as a byte offset.
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
let (h, n) = (hay.as_bytes(), needle.as_bytes());
if n.is_empty() || h.len() < n.len() {
return None;
}
(0..=h.len() - n.len()).find(|&i| h[i..i + n.len()].iter().zip(n).all(|(a, c)| a.eq_ignore_ascii_case(c)))
}
/// A web page as Markdown: headings, paragraphs, lists, tables, bold, links ("text (url)"). /// A web page as Markdown: headings, paragraphs, lists, tables, bold, links ("text (url)").
/// Scripts, styles and the <head> are dropped. /// Scripts, styles and the <head> are dropped.
pub fn html_markdown(html: &str) -> String { pub fn html_markdown(html: &str) -> String {
let mut s = html.to_string(); let mut s = html.to_string();
for tag in ["head", "script", "style", "noscript", "svg", "template"] { for tag in ["head", "script", "style", "noscript", "svg", "template"] {
loop { loop {
let low = s.to_lowercase(); let Some(a) = find_open_tag(&s, tag) else { break };
let Some(a) = low.find(&format!("<{tag}")).filter(|a| low[a + tag.len() + 1..].starts_with(|c: char| c == '>' || c.is_whitespace())) else { break }; let end = find_ci(&s[a..], &format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
let end = low[a..].find(&format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
s.replace_range(a..end, ""); s.replace_range(a..end, "");
} }
} }
@ -586,7 +614,12 @@ pub fn html_markdown(html: &str) -> String {
} }
fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> { fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
// calamine parses the whole workbook on the UI thread: a huge .xlsx froze the window.
// (CSV/TSV are fine: read_text reads only the head.)
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if matches!(ext.as_str(), "xlsx" | "xls" | "ods") && std::fs::metadata(path).map(|m| m.len() > 50 << 20).unwrap_or(false) {
return Err("這個試算表太大(超過 50 MB),在 AnyBot 裡預覽會卡住;請用其他 App 打開。".into());
}
if ext == "csv" || ext == "tsv" { if ext == "csv" || ext == "tsv" {
let text = read_text(path)?; let text = read_text(path)?;
let sep = if ext == "tsv" { '\t' } else { ',' }; let sep = if ext == "tsv" { '\t' } else { ',' };
@ -623,6 +656,11 @@ fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
/// An Excel date number (days since 1899-12-30) as "2026-10-31" or "2026-10-29 20:13". /// An Excel date number (days since 1899-12-30) as "2026-10-31" or "2026-10-29 20:13".
pub fn excel_date(v: f64) -> String { pub fn excel_date(v: f64) -> String {
// A cell formatted as a date can hold any number (Excel cells go to 1e308); beyond any
// real date, show the number — the date maths would overflow chrono and panic.
if !v.is_finite() || v.abs() > 3_000_000.0 {
return if v.fract().abs() < 0.000001 { format!("{}", v as i64) } else { format!("{v}") };
}
let base = chrono::NaiveDate::from_ymd_opt(1899, 12, 30).unwrap().and_hms_opt(0, 0, 0).unwrap(); let base = chrono::NaiveDate::from_ymd_opt(1899, 12, 30).unwrap().and_hms_opt(0, 0, 0).unwrap();
let t = base + chrono::Duration::milliseconds((v * 86_400_000.0).round() as i64); let t = base + chrono::Duration::milliseconds((v * 86_400_000.0).round() as i64);
if v.fract().abs() < 1e-9 { if v.fract().abs() < 1e-9 {