From 120cc152dcfd3d71d207f386ee9280d6fb672d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Fri, 5 Jun 2026 21:12:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(less-computer):=20=E5=85=A8=E5=B1=8F?= =?UTF-8?q?=E6=8F=8F=E8=BE=B9=E8=B4=B4=E8=BE=B9=E4=BF=AE=E5=A4=8D(?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=9D=90=E6=A0=87+=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=A0=8F=E5=B1=82=E7=BA=A7)+=E8=8B=B9=E6=9E=9C=E5=BC=8F?= =?UTF-8?q?=E9=AB=98=E6=96=AF=E6=9F=94=E5=85=89(=E7=BB=86=E5=B8=A6blur?= =?UTF-8?q?=E5=86=85=E5=8C=96)+=E6=8C=89=E4=B8=8B=E5=8D=B3=E4=BA=AE;=20?= =?UTF-8?q?=E5=BC=B9=E6=A1=86=E5=8F=AF=E6=8B=96=E5=8A=A8(=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E6=8B=96=E6=8A=8A=E6=89=8B+resize=E5=8E=9F=E4=BD=8D?= =?UTF-8?q?=E9=94=9A=E5=AE=9A);=20agent=E8=B6=85=E6=97=B6120=E2=86=92300s/?= =?UTF-8?q?=E9=A2=84=E7=AE=970.5=E2=86=922$?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/src/coordinator.rs | 28 +++++++-- .../src-tauri/src/coordinator/dictation.rs | 12 +++- openless-all/app/src-tauri/src/lib.rs | 50 +++++++++++++--- .../app/src/pages/LessComputerGlow.tsx | 59 ++++++++----------- .../app/src/pages/LessComputerPanel.tsx | 5 +- 5 files changed, 105 insertions(+), 49 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 99e3b07d..cab039db 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1526,10 +1526,24 @@ async fn handle_less_computer_pressed(inner: &Arc) { if begin_session(inner).await.is_err() { return; } - let mut state = inner.state.lock(); - if matches!(state.phase, SessionPhase::Starting | SessionPhase::Listening) { - state.voice_agent = true; - log::info!("[less-computer] voice session started (session={:?})", state.session_id); + let started = { + let mut state = inner.state.lock(); + if matches!(state.phase, SessionPhase::Starting | SessionPhase::Listening) { + state.voice_agent = true; + log::info!( + "[less-computer] voice session started (session={:?})", + state.session_id + ); + true + } else { + false + } + }; + // 一按下键(开始录音)就点亮整屏彩虹描边,贯穿 录音 → 处理 → 出结果,完成/关闭才熄灭。 + if started { + if let Some(app) = inner.app.lock().clone() { + crate::show_less_computer_glow(&app); + } } } @@ -1544,8 +1558,14 @@ async fn handle_less_computer_released(inner: &Arc) { match phase { SessionPhase::Listening => { let _ = end_session(inner).await; + // 收尾后熄灭整屏描边。正常路径 run_voice_agent_transcript 已熄过、这里兜底; + // 空转写/出错路径不进 run_voice_agent_transcript,全靠这里熄,否则描边卡住不灭。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } } SessionPhase::Starting => { + // 握手中松手:排队;真正收尾在 begin 续流的 end_session → run_voice_agent_transcript 熄灭。 request_stop_during_starting(inner, "less-computer release edge"); } _ => {} diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index f3790f8b..1dd5c1d3 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -594,7 +594,7 @@ async fn run_voice_agent_transcript( // 聊天浮窗:显示窗口 + 落用户气泡(语音指令转写)。macOS only(helper 内部 gating)。 if let Some(app) = inner.app.lock().clone() { crate::show_less_computer_window(&app); - crate::show_less_computer_glow(&app); + // 全屏彩虹描边已在按下键时(handle_less_computer_pressed)点亮,这里不重复。 } // 连续对话:浮窗里已有进行中的会话 → 本轮 `claude --continue` 续上下文;否则是新会话(fresh)。 // dismiss 关窗会把标志复位为 false。 @@ -764,8 +764,10 @@ async fn run_less_computer_once( "WebSearch".into(), ]; req.allowed_tools.extend(allow_rules); - req.max_budget_usd = Some(0.5); - req.timeout_secs = 120; + // 真实任务(开应用、多步操作、读写文件)常超过 120s/0.5$ → 老是「运行超时」。放宽到 + // 5 分钟 / 2$,给多步任务足够空间;仍有硬上限兜底,不会无限跑/烧钱。 + req.max_budget_usd = Some(2.0); + req.timeout_secs = 300; // 连续对话需要保留会话:本轮保存(供下轮 --continue),第二轮起带 --continue 续上下文。 req.session_persistence = true; req.continue_session = continue_session; @@ -2291,6 +2293,10 @@ pub(super) fn cancel_session(inner: &Arc) { emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None); log::info!("[coord] session cancelled (was {:?})", decision.phase); schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + // 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } } fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> usize { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1af21295..21ee04ee 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1384,15 +1384,25 @@ pub(crate) fn show_less_computer_glow(app: &AppHandle) { let Some(window) = app.get_webview_window("less-computer-glow") else { return; }; - // 盖满当前(否则主)显示器,含菜单栏/Dock 区域 —— 屏幕边缘环绕发光。 + // 盖满当前(否则主)显示器,含菜单栏/Dock 区域。关键:用「逻辑坐标」(物理/缩放) —— + // Retina 上 monitor.size() 是物理像素(2x),直接 set_size 会把窗口铺成两倍、错位、不贴边。 let monitor = window .current_monitor() .ok() .flatten() .or_else(|| app.primary_monitor().ok().flatten()); if let Some(monitor) = monitor { - let _ = window.set_position(*monitor.position()); - let _ = window.set_size(*monitor.size()); + let scale = monitor.scale_factor(); + let size = monitor.size(); + let pos = monitor.position(); + let _ = window.set_position(tauri::LogicalPosition::new( + pos.x as f64 / scale, + pos.y as f64 / scale, + )); + let _ = window.set_size(tauri::LogicalSize::new( + size.width as f64 / scale, + size.height as f64 / scale, + )); } // 点击穿透:纯视觉浮层,绝不拦截鼠标。 let _ = window.set_ignore_cursor_events(true); @@ -1407,6 +1417,11 @@ pub(crate) fn show_less_computer_glow(app: &AppHandle) { let _ = window_clone.show(); } else { unsafe { + // 抬到菜单栏(24)/Dock 之上,让描边能真正贴到屏幕最外缘(含顶部菜单栏区域)。 + let _: () = msg_send![ns, setLevel: 25i64]; + // 所有 Space 都显示、不参与窗口循环、全屏 app 上也叠加。 + let _: () = msg_send![ns, setCollectionBehavior: 273u64]; + let _: () = msg_send![ns, setIgnoresMouseEvents: true]; let _: () = msg_send![ns, orderFrontRegardless]; } } @@ -1432,12 +1447,33 @@ pub(crate) fn hide_less_computer_glow(app: &AppHandle) { #[cfg(not(target_os = "macos"))] pub(crate) fn hide_less_computer_glow(_app: &AppHandle) {} -/// 前端按内容测高后回传,Rust 端 clamp + bottom-anchored 重新摆放。`macos` 专用。 +/// 前端按内容测高后回传。以「当前窗口底边」为锚向上生长——只改高度、保住用户拖动后的位置, +/// 不再重新居中(否则一改内容就把拖走的框拉回屏幕底部中间)。`macos` 专用。 #[cfg(target_os = "macos")] pub(crate) fn resize_less_computer_window(app: &AppHandle, height: f64) { - if let Some(window) = app.get_webview_window("less-computer") { - if let Err(e) = position_less_computer_window(&window, height) { - log::warn!("[less-computer] resize failed: {e}"); + let Some(window) = app.get_webview_window("less-computer") else { + return; + }; + let height = height.clamp( + LESS_COMPUTER_WINDOW_MIN_HEIGHT, + LESS_COMPUTER_WINDOW_MAX_HEIGHT, + ); + let scale = window.scale_factor().unwrap_or(1.0); + match (window.outer_position(), window.outer_size()) { + (Ok(pos), Ok(size)) => { + let x = pos.x as f64 / scale; + let cur_top = pos.y as f64 / scale; + let cur_h = size.height as f64 / scale; + let bottom = cur_top + cur_h; + let new_y = (bottom - height).max(0.0); + let _ = window.set_size(tauri::LogicalSize::new(LESS_COMPUTER_WINDOW_WIDTH, height)); + let _ = window.set_position(tauri::LogicalPosition::new(x, new_y)); + } + // 拿不到当前位置(极少见)→ 退回首屏居中摆放。 + _ => { + if let Err(e) = position_less_computer_window(&window, height) { + log::warn!("[less-computer] resize fallback failed: {e}"); + } } } } diff --git a/openless-all/app/src/pages/LessComputerGlow.tsx b/openless-all/app/src/pages/LessComputerGlow.tsx index 55f71386..d8eed35e 100644 --- a/openless-all/app/src/pages/LessComputerGlow.tsx +++ b/openless-all/app/src/pages/LessComputerGlow.tsx @@ -1,50 +1,48 @@ // Less Computer 全屏彩虹「跑马灯」描边浮层(独立窗口 window=less-computer-glow)。 // -// 思路(参考开源 MacEdgeLight / Apple Edge Light):一个全屏、点击穿透、置顶、透明的 -// 覆盖窗,沿屏幕圆角画一圈旋转的彩虹环 + 柔光,营造「整屏被 AI 点亮」的氛围感。 -// 纯视觉:pointer-events:none,且后端对该窗口 set_ignore_cursor_events(true)。 -// 仅 macOS 实际显示(后端只在 macOS show/hide 这个窗口)。 +// 思路(参考开源 MacEdgeLight / Apple Edge Light,自写实现):一个全屏、点击穿透、置顶、 +// 透明的覆盖窗,沿屏幕边缘画一圈「贴边清晰、向内高斯模糊羽化」的流动彩虹辉光。 +// - 细带 conic-gradient 彩虹环,首尾同色; +// - 重 blur() 高斯模糊 → 既把环糊成柔光、又彻底抹平 conic 起始角的断层; +// - 容器 overflow:hidden 把外溢的模糊裁在屏幕最外缘 → 边缘清晰、内部模糊(苹果那种感觉); +// - 旋转 = 跑马灯流动,呼吸 = 明暗起伏。 +// 纯视觉:pointer-events:none,后端再 set_ignore_cursor_events(true)。仅 macOS 显示。 const glowCss = ` @property --lcg-angle { syntax: ''; initial-value: 0deg; inherits: false; } @keyframes lcg-spin { to { --lcg-angle: 360deg; } } -@keyframes lcg-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } -@keyframes lcg-glow-breathe { 0%, 100% { opacity: .35; } 50% { opacity: .7; } } +@keyframes lcg-breathe { 0%, 100% { opacity: .58; } 50% { opacity: 1; } } html, body, #root { background: transparent !important; margin: 0; height: 100%; overflow: hidden; } -/* 旋转的彩虹环:conic-gradient 首尾同色(无断层)→ mask 抠出一圈描边 → 绕 --lcg-angle 旋转。 */ -.lcg-ring { +/* 全屏裁剪容器:圆角贴合屏幕物理圆角;overflow:hidden 把外溢模糊裁在屏幕边缘。 */ +.lcg-root { position: fixed; inset: 0; pointer-events: none; - border-radius: var(--lcg-radius, 40px); - padding: var(--lcg-width, 5px); + overflow: hidden; + border-radius: var(--lcg-radius, 34px); +} +/* 彩虹环:略微跨出屏幕边缘(inset 负) → 满色部分压在最外缘(清晰),blur 向内羽化(模糊)。 + 细带(padding) + 重 blur = 苹果 Edge Light 的柔光,而不是一条又粗又硬的描边。 */ +.lcg-root::before { + content: ''; + position: absolute; + inset: -5px; + border-radius: calc(var(--lcg-radius, 34px) + 5px); + padding: 9px; background: conic-gradient(from var(--lcg-angle), - #ff2d78, #ff8a00, #ffd400, #38e08a, #00c2ff, #7a5cff, #ff2d78); + #ff3b6b, #ff8a3d, #ffe14d, #46e08a, #36c6ff, #9b6bff, #ff3b6b); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; - animation: lcg-spin 6s linear infinite, lcg-breathe 5s ease-in-out infinite; - filter: saturate(1.15) blur(0.4px); -} - -/* 柔光层:贴着屏幕边往内渗的同色辉光(体积感),让描边不是一条硬线而是「发光」。 */ -.lcg-soft { - position: fixed; - inset: 0; - pointer-events: none; - border-radius: var(--lcg-radius, 40px); - box-shadow: - inset 0 0 38px 4px rgba(124, 92, 255, 0.20), - inset 0 0 90px 18px rgba(0, 194, 255, 0.10); - animation: lcg-glow-breathe 5s ease-in-out infinite; + filter: blur(22px) saturate(1.3); + animation: lcg-spin 6s linear infinite, lcg-breathe 4.5s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { - .lcg-ring { animation: none; opacity: .8; } - .lcg-soft { animation: none; } + .lcg-root::before { animation: none; opacity: .85; } } `; @@ -56,10 +54,5 @@ if (typeof document !== 'undefined' && !document.getElementById('less-computer-g } export function LessComputerGlow() { - return ( - <> -
-
- - ); + return
; } diff --git a/openless-all/app/src/pages/LessComputerPanel.tsx b/openless-all/app/src/pages/LessComputerPanel.tsx index c0b68cce..6493bf76 100644 --- a/openless-all/app/src/pages/LessComputerPanel.tsx +++ b/openless-all/app/src/pages/LessComputerPanel.tsx @@ -247,8 +247,9 @@ function TurnView({ function Toolbar({ label, onClose }: { label: string; onClose: () => void }) { return ( -
-
+ // 顶栏作为拖动把手:按住空白处可把整个聊天框拖到屏幕任意位置(resize 会保住拖后的位置)。 +
+