diff --git a/src/app.rs b/src/app.rs index 1064159..2391d59 100644 --- a/src/app.rs +++ b/src/app.rs @@ -943,6 +943,9 @@ impl App { for id in std::mem::take(&mut self.group_next) { if !self.streams.contains_key(&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) { @@ -951,7 +954,13 @@ impl App { b.messages.push(Message::user(&t)); } } - self.start_reply(ctx, id); + 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); + } } } @@ -1239,9 +1248,11 @@ impl App { StreamEvent::Steered(m) => b.messages.push(m), StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)), StreamEvent::ToolResult(m) => b.messages.push(m), - StreamEvent::Compacted(n, summary) => { - let n = n.min(b.messages.len()); - b.messages.splice(..n, [summary]); + StreamEvent::Compacted(n, summary, boundary) => { + // The model's history can be shorter than the window's chat (group chats + // 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) => { 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); } else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() { 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; @@ -3238,7 +3249,8 @@ impl App { std::thread::sleep(std::time::Duration::from_millis(100)); } 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 one = s.replace('\n', " ⏎ "); if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::()) } else { one } diff --git a/src/app/files_ui.rs b/src/app/files_ui.rs index c2497d3..e980181 100644 --- a/src/app/files_ui.rs +++ b/src/app/files_ui.rs @@ -96,6 +96,18 @@ fn start_job(kind: &str, path: &str) -> Option>> { 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" => { std::thread::spawn(move || { let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms())); @@ -258,8 +270,30 @@ impl App { }); } Preview::Image => { + // The picture is decoded on a worker thread (start_job "image"). if fv.image.is_none() { - fv.image = load_texture(ui.ctx(), &fv.path); + 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); + } } match &fv.image { Some(tex) => { diff --git a/src/app/markdown_ui.rs b/src/app/markdown_ui.rs index b64b322..8422933 100644 --- a/src/app/markdown_ui.rs +++ b/src/app/markdown_ui.rs @@ -777,6 +777,12 @@ impl App { let dark = p.pane != LIGHT.pane; let key = key_of(src, dark); 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 (s2, src2) = (slot.clone(), src.to_string()); let ctx = ui.ctx().clone(); diff --git a/src/app/vault_ui.rs b/src/app/vault_ui.rs index 5cc1c3f..ed13360 100644 --- a/src/app/vault_ui.rs +++ b/src/app/vault_ui.rs @@ -587,8 +587,11 @@ impl App { VaultDialog::Import(mut st) => { if self.vault_import(ctx, &mut 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; diff --git a/src/icons.rs b/src/icons.rs index 4a5a2a9..153cddf 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -250,13 +250,19 @@ fn rasterize(icon: Icon, px: u32) -> Option { pub fn paint(p: &Painter, rect: Rect, icon: Icon, col: Color32) { let ppp = p.ctx().pixels_per_point(); 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 mut c = cache().lock().unwrap(); match c.get(&(icon, px)) { Some(t) => t.clone(), None => { 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); c.insert((icon, px), t.clone()); t diff --git a/src/notify.rs b/src/notify.rs index a344f18..d3ba116 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -90,6 +90,7 @@ pub fn screensaver_active() -> bool { std::thread::sleep(std::time::Duration::from_millis(20)); } let _ = c.kill(); + let _ = c.wait(); false } @@ -116,10 +117,10 @@ pub fn banner(title: &str, body: &str) { if !cfg!(target_os = "macos") { // The desktop's notification service (GNOME, KDE, XFCE…) through notify-send. 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; } - 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)] diff --git a/src/preview.rs b/src/preview.rs index 0402a2d..632cf3e 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -114,7 +114,8 @@ pub fn system_open(target: impl AsRef) -> std::process::Command } 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}"); } } @@ -138,7 +139,13 @@ fn clip(t: String) -> String { } fn read_text(path: &Path) -> Result { - 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) { return Err("這是二進位檔案,沒辦法當文字顯示。".into()); } @@ -456,15 +463,36 @@ fn md_table(rows: &[Vec]) -> String { s } +/// Find `` 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 { + 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 { + 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)"). /// Scripts, styles and the are dropped. pub fn html_markdown(html: &str) -> String { let mut s = html.to_string(); for tag in ["head", "script", "style", "noscript", "svg", "template"] { loop { - let low = s.to_lowercase(); - 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 = low[a..].find(&format!("")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len()); + let Some(a) = find_open_tag(&s, tag) else { break }; + let end = find_ci(&s[a..], &format!("")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len()); s.replace_range(a..end, ""); } } @@ -586,7 +614,12 @@ pub fn html_markdown(html: &str) -> String { } fn load_sheet(path: &Path, name: &str) -> Result { + // 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(); + 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" { let text = read_text(path)?; let sep = if ext == "tsv" { '\t' } else { ',' }; @@ -623,6 +656,11 @@ fn load_sheet(path: &Path, name: &str) -> Result { /// 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 { + // 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 t = base + chrono::Duration::milliseconds((v * 86_400_000.0).round() as i64); if v.fract().abs() < 1e-9 {