feat: 统一聊天面板(官方 shadcn)+ 1.3.14 审计修复批 + CVE 补丁 (#767)

* fix(deps): patch tauri-plugin-shell CVE (GHSA-c9pr-q8gx-3mgp)

@tauri-apps/plugin-shell (npm) 与 tauri-plugin-shell (crate) 的声明下限
——npm `^2.0.1`、crate `"2"`——都落在 GHSA-c9pr-q8gx-3mgp 影响范围
(`< 2.2.1`,CRITICAL:`open` 端点作用域校验绕过,可致 RCE)。本项目
src/lib/ipc/utils.ts 正是通过该 `open` 端点打开外链,属受影响用法。

两个 lockfile 实际已解析到 2.3.5,但清单声明的下限仍是漏洞版本,供应链
扫描(SkillSpector SC4)因此读取到 2.0.1 报警。此处仅把两侧声明下限抬到
最新安全版 2.3.5,使清单与锁定版本一致、扫描不再命中;解析版本不变,无
运行时行为变化。

Closes #668

* fix(audit): 1.3.14 审计修复一批(Apple Speech 取消/IME 保存锁死/侧向键卡死/火山429 等)

- CRIT Apple Speech 取消/超时不终止 SFSpeechRecognitionTask(SendableTask+cancel_flag+[task cancel]+100ms 轮询)
- HIGH windows_ime 未安装分支反致设置保存事务锁死(抽纯函数 keyboard_list_pref_short_circuit)
- HIGH 侧向键 Released 卡死自愈 + macOS flags 类位竞态 + Linux 显式拒绝
- HIGH coordinator/hotkey_loops unused_assignments clippy 回归
- MED 火山 429 限流不重试(RateLimited)/ Heatmap clamp / 授权失败反馈 / SendInput 分类单一真相 / SendInputOptions 收敛
- 清理 删死码 ?? '重启' / side_aware 死字段 / 孤儿 MarketplaceModal

cargo test 552-0 / npm build 绿。windows cfg 部分需 CI 四平台验证。

* feat(chat): 统一聊天面板组件库——官方 shadcn base 组件按 CLI 规则内嵌

@shadcn/react 官方 primitive + base-rhea registry 原文内嵌 components/chat/ui/
(message-scroller/marker/bubble/message/card/empty/input-group/button/input/
textarea/spinner,仅按 CLI 规则改 import 路径;button 保留 3 行 React18 inert
归一)。Tailwind v4 作用域隔离:utilities source(none) + @source 只扫聊天文件、
无层导入压过 global.css;官方主题 shadcn/tailwind.css + tw-animate-css。
胶囊同款思考动画(orbFeed 单 GL 源镜像多头像)、GitHub 头像(CSP img-src 放行)、
出现/消失生命周期 hook。平台门控:非 darwin 构建 DCE 掉 Less Computer chunk。
删除旧自制 Marker/MessageScroller 组件。

* feat(panels): QA/LC 浮窗重写为官方 message-scroller-demo 同款骨架

Card + Empty + InputGroup + MessageScroller 官方组合;QA=Message 带头像形态
(右 GitHub / 左旋转 orb),LC=Marker 工具行形态(无头像)。
语音状态只在输入区:录音红光/转译黑光绕输入组跑圈,问题落定即停;
思考头像行门控在用户消息落定之后(修「AI 头像先出现、动画抽搐」)。
LC 事件空轮自愈 + mount 后 less_computer_sync 重放(webview 冷加载丢
user 事件导致「后端在跑前端空白/用户的话不显示」)。纯白背景、官方
24px 圆角、出现/消失动画(chat-panel:shown/closing)。

* fix(backend): 聊天面板后端配套——命令注册/非激活面板/事件缓冲/生命周期

- 恢复桌面 invoke_handler 的 qa_submit_text / qa_toggle_recording(此前删
  qa_window_pin 时误删相邻项,真机报 Command qa_submit_text not found)
- QA/LC 窗口转非激活 NSPanel(胶囊同管线:NonactivatingPanelMask +
  CanJoinAllSpaces|FullScreenAuxiliary),chat_panel_focus_keyboard 改主线程
  makeKeyAndOrderFront:打字不激活 app、主窗口不再被带出、frontmost 保持
  用户原 app(划词选区在多轮期间仍可读)
- Less Computer 事件按全局单调 seq 写入环形缓冲(fresh user 清空、cap 2048
  丢最旧),新命令 less_computer_sync 供前端 mount 重放(仅 less-computer
  窗口可调);附 2 个单测
- show/hide 生命周期:show 发 chat-panel:shown 并作废挂起 hide;hide 先发
  chat-panel:closing、240ms 后真正隐藏(AtomicU64 世代防快速关-开竞态)
- QA/LC 窗口统一固定 420×540;删除 qa_window_pin / less_computer_window_resize
  整链(无消费方)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-07-06 22:21:16 +08:00
1 parent 2e3c0f5c29
commit d8789c783d
53 files changed
+7852 -2082

No files matched your search

+4332 -78
View File
File diff suppressed because it is too large. Load diff
+17 -1
View File
@@ -23,27 +23,43 @@
"check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@formkit/auto-animate": "^0.9.0",
"@shadcn/react": "^0.2.0",
"@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"@types/dompurify": "^3.0.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.8",
"framer-motion": "^12.39.0",
"i18next": "^26.0.8",
"lucide-react": "^1.23.0",
"marked": "^11.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.6"
"react-i18next": "^17.0.6",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"shadcn": "^4.13.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.6.3",
"vite": "^6.4.3"
},
"overrides": {
"@shadcn/react": {
"react": "$react",
"@types/react": "$@types/react"
}
}
}
@@ -17,7 +17,9 @@
#![cfg(target_os = "macos")]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
@@ -38,12 +40,33 @@ const SF_AUTH_AUTHORIZED: i64 = 3;
/// 等待识别 / 授权回调的兜底超时。识别本身另有 coordinator 侧动态超时;
/// 这里只防 block 永不回调导致线程永久阻塞。
const RECOGNITION_WAIT: Duration = Duration::from_secs(60);
/// 识别等待的轮询步长。把 `recv_timeout(RECOGNITION_WAIT)` 的一次性阻塞拆成每
/// `RECOGNITION_POLL` 一轮:每轮之间检查 `cancel_flag`,取消 / 上层超时后阻塞线程
/// 最多再等这一步长(~100ms)就退出,而不是傻等满 `RECOGNITION_WAIT`(60s)。
const RECOGNITION_POLL: Duration = Duration::from_millis(100);
const AUTHORIZATION_WAIT: Duration = Duration::from_secs(30);
/// 识别引擎就绪(isAvailable)的轮询等待:刚 init 的 recognizer 常瞬时不可用(异步
/// 加载语言资源),稍等即就绪。等满仍不可用才报错——修「有时用不了」的竞态。
const AVAILABILITY_WAIT: Duration = Duration::from_secs(3);
const AVAILABILITY_POLL: Duration = Duration::from_millis(100);
/// `SFSpeechRecognitionTask` 的裸指针包装,仅为把 task 句柄从 spawn_blocking 线程
/// 存进 `AppleSpeechAsr::active_task`,供任意线程(含 tokio 上取消的线程)调用
/// `-[SFSpeechRecognitionTask cancel]` 终止识别。
///
/// SAFETY: `SFSpeechRecognitionTask` 是标准的 objc/ARC 对象,其 `cancel` 属于
/// Speech.framework 文档承诺可从任意线程安全调用的操作(内部转派到自身队列);
/// 我们对该指针只做两件事——存入 `active_task`、以及调用 `cancel`——不做解引用、
/// 不改内部状态。指针仅在对应识别请求存活期间被持有:`recognize_file` 返回前会把
/// `active_task` 置回 `None`,此时 recognizer / request 仍在同一栈帧强引用存活,
/// task 不会被提前释放。因此跨线程传递该裸指针并调用 `cancel` 不违反内存/线程安全。
/// 不实现 `Sync`——它只在 `Mutex` 保护下被取出后使用,无需并发共享引用。
struct SendableTask(*mut AnyObject);
// SAFETY: 见 `SendableTask` 文档注释——底层 SFSpeechRecognitionTask 线程安全,
// `cancel` 可跨线程调用,包装体只承载指针用于「存」与「取消」。
unsafe impl Send for SendableTask {}
pub struct AppleSpeechAsr {
/// 16-bit LE PCM 字节缓冲(recorder 推什么我们存什么)。与 LocalQwenAsr 同形。
buffer: Mutex<Vec<u8>>,
@@ -51,6 +74,13 @@ pub struct AppleSpeechAsr {
/// 而来 —— SFSpeechRecognizer 一个实例只认一种语言,不显式指定就落到系统首选语言
/// (常是英文),中文语音会被英文引擎识别成英文且理解错误(用户报告的根因)。
locale: Option<String>,
/// 取消标志。`cancel()` 置位;`recognize_file` 的等待轮询每轮检查,置位即放弃
/// 等待并真正 `cancel` 底层识别任务 —— 让被上层动态超时抛弃 / 被 `cancel()` 的
/// spawn_blocking 阻塞线程在 ~100ms 内退出,而不是傻等满 `RECOGNITION_WAIT`。
cancel_flag: Arc<AtomicBool>,
/// 当前在飞的识别任务句柄。`recognize_file` 拿到 task 即存入,返回前清空;
/// `cancel()` 从这里取出并调用 `-[SFSpeechRecognitionTask cancel]` 终止识别。
active_task: Arc<Mutex<Option<SendableTask>>>,
}
impl AppleSpeechAsr {
@@ -58,6 +88,8 @@ impl AppleSpeechAsr {
Self {
buffer: Mutex::new(Vec::new()),
locale,
cancel_flag: Arc::new(AtomicBool::new(false)),
active_task: Arc::new(Mutex::new(None)),
}
}
@@ -84,11 +116,22 @@ impl AppleSpeechAsr {
let duration_ms = (pcm.len() as u64 / 2) * 1000 / 16_000;
let locale = self.locale.clone();
// 本次识别开始前复位取消标志:上一会话若以取消收尾,标志可能仍为 true。
self.cancel_flag.store(false, Ordering::SeqCst);
let cancel_flag = Arc::clone(&self.cancel_flag);
let active_task = Arc::clone(&self.active_task);
// SFSpeechRecognizer 是阻塞且基于 objc runloop 的同步桥接;放到
// spawn_blocking 不占 tokio runtime。与 LocalQwenAsr 走同一个 Tauri
// 持有的 runtime handle。
let result = tauri::async_runtime::spawn_blocking(move || {
transcribe_pcm_blocking(&pcm, duration_ms, locale.as_deref())
transcribe_pcm_blocking(
&pcm,
duration_ms,
locale.as_deref(),
&cancel_flag,
&active_task,
)
})
.await
.context("apple-speech transcribe spawn_blocking join 失败")?;
@@ -100,6 +143,17 @@ impl AppleSpeechAsr {
}
pub fn cancel(&self) {
// 先置位取消标志:等待轮询下一轮(~100ms 内)看到即放弃等待并退出阻塞线程。
self.cancel_flag.store(true, Ordering::SeqCst);
// 再真正终止在飞的识别任务(若有)。取出句柄后立即调用 cancel。
if let Some(task) = self.active_task.lock().take() {
// SAFETY: `task.0` 是 `recognitionTaskWithRequest:` 返回的
// SFSpeechRecognitionTask 指针。`-[SFSpeechRecognitionTask cancel]` 无参、
// 无返回值,是 Speech.framework 承诺可从任意线程调用的操作。此处仅调用
// cancel、不解引用指针;调用后不再使用该句柄(已 take 出 Option)。
let _: () = unsafe { msg_send![task.0, cancel] };
log::info!("[apple-speech] recognition task cancelled");
}
self.buffer.lock().clear();
}
}
@@ -122,6 +176,8 @@ fn transcribe_pcm_blocking(
pcm: &[u8],
duration_ms: u64,
locale: Option<&str>,
cancel_flag: &AtomicBool,
active_task: &Mutex<Option<SendableTask>>,
) -> Result<RawTranscript> {
ensure_authorized()?;
@@ -143,7 +199,7 @@ fn transcribe_pcm_blocking(
let path_str = path
.to_str()
.ok_or_else(|| anyhow!("临时 wav 路径含非 UTF-8 字符: {}", path.display()))?;
let text = recognize_file(path_str, locale)?;
let text = recognize_file(path_str, locale, cancel_flag, active_task)?;
Ok(RawTranscript { text, duration_ms })
}
@@ -197,7 +253,17 @@ fn ensure_authorized() -> Result<()> {
/// 用 `SFSpeechURLRecognitionRequest` 对给定 wav 文件做一次批处理识别,
/// 把 `recognitionTaskWithRequest:resultHandler:` 的异步回调同步化。
fn recognize_file(wav_path: &str, locale: Option<&str>) -> Result<String> {
///
/// 等待不再是一次性 `recv_timeout(RECOGNITION_WAIT)`,而是每 `RECOGNITION_POLL`
/// 一轮的轮询:每轮检查 `cancel_flag`,置位则 `cancel` 底层任务并返回「已取消」错误,
/// 让上层动态超时抛弃 / `cancel()` 触发时阻塞线程在 ~100ms 内退出。返回前无论成败
/// 都清空 `active_task`(RAII guard 兜底 `?` 早退)。
fn recognize_file(
wav_path: &str,
locale: Option<&str>,
cancel_flag: &AtomicBool,
active_task: &Mutex<Option<SendableTask>>,
) -> Result<String> {
let recognizer = create_recognizer(locale)?;
// 识别引擎就绪等待(isAvailable 竞态):SFSpeechRecognizer 刚 init 时引擎往往还没
@@ -226,8 +292,9 @@ fn recognize_file(wav_path: &str, locale: Option<&str>) -> Result<String> {
log::info!("[apple-speech] starting recognitionTaskWithRequest");
// SAFETY: `recognizer` 有效;`request` 是有效的 `SFSpeechURLRecognitionRequest`;
// `&*block` 是稳定 block 指针,block 本体被 `block` 持有至本作用域结束。
// 返回的 `SFSpeechRecognitionTask` 我们不持有(自身被 recognizer 强引用直到完成)。
let _task: *mut AnyObject = unsafe {
// 返回的 `SFSpeechRecognitionTask` 自身被 recognizer 强引用直到完成;我们额外把
// 句柄存进 `active_task` 供 `cancel()` 从别的线程终止它(见 SendableTask 文档)。
let task: *mut AnyObject = unsafe {
msg_send![
recognizer,
recognitionTaskWithRequest: request,
@@ -235,11 +302,47 @@ fn recognize_file(wav_path: &str, locale: Option<&str>) -> Result<String> {
]
};
match rx.recv_timeout(RECOGNITION_WAIT) {
Ok(RecognitionOutcome::Final(text)) => Ok(text),
Ok(RecognitionOutcome::Failed(message)) => bail!("语音识别失败: {message}"),
Ok(RecognitionOutcome::Pending) => unreachable!("Pending 不会被发送"),
Err(err) => bail!("等待语音识别结果超时或失败: {err}"),
// 存句柄供 cancel();guard 保证本函数任意退出路径都把它清回 None,避免悬挂。
*active_task.lock() = Some(SendableTask(task));
let _task_guard = ActiveTaskGuard(active_task);
// 轮询等待:每轮 recv 最多 RECOGNITION_POLL,累计超过 RECOGNITION_WAIT 则超时。
// 每轮先查 cancel_flag —— 置位即真正 cancel 任务并返回「已取消」错误、放弃等待。
let deadline = std::time::Instant::now() + RECOGNITION_WAIT;
loop {
if cancel_flag.load(Ordering::SeqCst) {
// 若 cancel() 尚未取走句柄(例如超时路径只置了 flag 没调 cancel),这里补发
// 一次 cancel,确保底层识别任务被真正终止,而不是留它在后台跑满。
if let Some(t) = active_task.lock().take() {
// SAFETY: 见 SendableTask 文档 —— `cancel` 无参、可跨线程调用,仅调用不解引用。
let _: () = unsafe { msg_send![t.0, cancel] };
}
bail!("语音识别已取消");
}
match rx.recv_timeout(RECOGNITION_POLL) {
Ok(RecognitionOutcome::Final(text)) => return Ok(text),
Ok(RecognitionOutcome::Failed(message)) => bail!("语音识别失败: {message}"),
Ok(RecognitionOutcome::Pending) => unreachable!("Pending 不会被发送"),
Err(mpsc::RecvTimeoutError::Timeout) => {
if std::time::Instant::now() >= deadline {
bail!("等待语音识别结果超时");
}
// 未到总时限:继续下一轮(回到循环顶部再查 cancel_flag)。
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
bail!("等待语音识别结果失败:回调通道已断开");
}
}
}
}
/// 保证 `recognize_file` 任意退出路径(含 `?` 早退、正常返回、取消/超时)都把
/// `active_task` 清回 `None`,避免悬挂的 task 句柄被后续 `cancel()` 误用。
struct ActiveTaskGuard<'a>(&'a Mutex<Option<SendableTask>>);
impl Drop for ActiveTaskGuard<'_> {
fn drop(&mut self) {
*self.0.lock() = None;
}
}
@@ -540,4 +643,72 @@ mod tests {
let b = unique_suffix();
assert!(b > a);
}
#[test]
fn cancel_flag_defaults_false_and_set_by_cancel() {
let asr = AppleSpeechAsr::new(None);
assert!(
!asr.cancel_flag.load(Ordering::SeqCst),
"取消标志初值应为 false"
);
asr.cancel();
assert!(
asr.cancel_flag.load(Ordering::SeqCst),
"cancel() 应把取消标志置位,让等待轮询下一轮退出"
);
}
#[test]
fn active_task_defaults_none() {
let asr = AppleSpeechAsr::new(None);
assert!(
asr.active_task.lock().is_none(),
"尚未发起识别时 active_task 应为 None"
);
}
#[test]
fn active_task_guard_clears_handle_on_drop() {
// active_task 存了句柄后,ActiveTaskGuard 掉出作用域应把它清回 None。
// 用 dangling 指针仅做占位:guard 的 Drop 只 take + 置 None,不触碰指针内容。
let slot: Mutex<Option<SendableTask>> = Mutex::new(None);
*slot.lock() = Some(SendableTask(std::ptr::null_mut()));
assert!(slot.lock().is_some());
{
let _guard = ActiveTaskGuard(&slot);
}
assert!(
slot.lock().is_none(),
"ActiveTaskGuard drop 后 active_task 必须清空,避免悬挂句柄"
);
}
#[test]
fn cancel_on_empty_active_task_is_noop_and_sets_flag() {
// active_task 为 None 时 cancel() 不应发起任何 objc 调用,只置标志 + 清缓冲。
let asr = AppleSpeechAsr::new(None);
assert!(asr.active_task.lock().is_none());
asr.cancel(); // 不得 panic
assert!(asr.cancel_flag.load(Ordering::SeqCst));
assert!(asr.active_task.lock().is_none());
}
#[tokio::test]
async fn transcribe_empty_buffer_short_circuits_before_flag_reset() {
// 空缓冲在复位取消标志之前就提前 return,因此不进入识别逻辑,flag 维持原值。
// 这条固定住短路顺序:只有真正要识别(缓冲非空)时才会复位并进入轮询。
let asr = AppleSpeechAsr::new(None);
asr.cancel_flag.store(true, Ordering::SeqCst);
let out = asr.transcribe().await.unwrap();
assert_eq!(out.text, "");
assert!(asr.cancel_flag.load(Ordering::SeqCst));
}
#[test]
fn sendable_task_is_send() {
// 编译期断言:SendableTask 必须是 Send,才能被 spawn_blocking 捕获跨线程存取。
fn assert_send<T: Send>() {}
assert_send::<SendableTask>();
assert_send::<Arc<Mutex<Option<SendableTask>>>>();
}
}
@@ -66,6 +66,12 @@ pub enum VolcengineASRError {
/// 文案简短,原因在文档里说明,capsule 不堆长引导。
#[error("凭据被拒({0})")]
AuthRejected(u16),
/// WebSocket 握手阶段服务端返回 429:请求过多 / 账号被限流。
/// 单独归类而非落入 `ConnectionFailed` —— 后者会被 `connect_with_retry` 当网络抖动
/// 立即重试 3 次,反而加剧限流、且文案含糊指向「网络失败」误导用户。此类与
/// `AuthRejected` 一样**短路不重试**:立即回带明确文案,让用户知道是限流不是断网。
#[error("请求过多,账号被限流({0})")]
RateLimited(u16),
#[error("no final result")]
NoFinalResult,
#[error("final result timed out")]
@@ -279,7 +285,8 @@ impl VolcengineStreamingASR {
/// Connect with a per-attempt timeout and bounded retries so a poor network
/// (hung handshake or a transient blip) doesn't kill the whole dictation.
/// `AuthRejected` short-circuits — bad credentials never heal on retry.
/// `AuthRejected` / `RateLimited` short-circuit — bad credentials never heal on
/// retry, and hammering a rate-limited account only makes the throttle worse.
async fn connect_with_retry(&self, connect_id: &str) -> Result<WsStream, VolcengineASRError> {
let mut attempt = 0usize;
loop {
@@ -289,9 +296,7 @@ impl VolcengineStreamingASR {
Ok(Ok((ws, _resp))) => return Ok(ws),
Ok(Err(e)) => {
let classified = classify_connect_error(e);
if matches!(classified, VolcengineASRError::AuthRejected(_))
|| attempt >= CONNECT_MAX_ATTEMPTS
{
if is_non_retryable(&classified) || attempt >= CONNECT_MAX_ATTEMPTS {
return Err(classified);
}
log::warn!(
@@ -741,7 +746,8 @@ fn normalized_result(json: &Value) -> Option<&Value> {
}
/// 把 tokio-tungstenite 的 connect 错误分类:握手收到 HTTP 401 / 403 → `AuthRejected`
/// (凭据被拒,要 user 检查 App ID / Access Token / 账号资源开通状态);其它 → 通用
/// (凭据被拒,要 user 检查 App ID / Access Token / 账号资源开通状态);429 →
/// `RateLimited`(请求过多 / 限流,重试只会火上浇油,短路报明确文案);其它 → 通用
/// `ConnectionFailed`(DNS / TLS / 网络层)。让 capsule 文案能跟泛泛 HTTP error 区分。
fn classify_connect_error(err: tokio_tungstenite::tungstenite::Error) -> VolcengineASRError {
use tokio_tungstenite::tungstenite::Error as WsError;
@@ -750,10 +756,23 @@ fn classify_connect_error(err: tokio_tungstenite::tungstenite::Error) -> Volceng
if status == 401 || status == 403 {
return VolcengineASRError::AuthRejected(status);
}
if status == 429 {
return VolcengineASRError::RateLimited(status);
}
}
VolcengineASRError::ConnectionFailed(err.to_string())
}
/// 握手错误是否「重试也无益」,`connect_with_retry` 据此短路。凭据被拒(401/403)
/// 与限流(429)都属此类:前者重试不会变对,后者重试只会加剧限流。其余(网络层)
/// 才值得在抖动时重试。
fn is_non_retryable(err: &VolcengineASRError) -> bool {
matches!(
err,
VolcengineASRError::AuthRejected(_) | VolcengineASRError::RateLimited(_)
)
}
fn hotword_context(entries: &[DictionaryHotword]) -> Option<String> {
let mut seen: Vec<String> = Vec::new();
for entry in entries {
@@ -840,6 +859,67 @@ mod tests {
);
}
/// 构造一个握手阶段返回给定 HTTP 状态码的 tungstenite 错误,用于分类测试。
fn http_ws_error(status: u16) -> tokio_tungstenite::tungstenite::Error {
use tokio_tungstenite::tungstenite::http::Response;
let resp = Response::builder()
.status(status)
.body(None)
.expect("build test http response");
tokio_tungstenite::tungstenite::Error::Http(resp)
}
#[test]
fn classify_429_is_rate_limited_not_connection_failed() {
let classified = classify_connect_error(http_ws_error(429));
assert!(
matches!(classified, VolcengineASRError::RateLimited(429)),
"429 应归类为 RateLimited 而非 ConnectionFailed,避免被当网络抖动重试"
);
}
#[test]
fn rate_limited_is_non_retryable_like_auth_rejected() {
assert!(
is_non_retryable(&VolcengineASRError::RateLimited(429)),
"限流不可重试:重试只会加剧限流"
);
assert!(
is_non_retryable(&VolcengineASRError::AuthRejected(401)),
"凭据被拒不可重试"
);
}
#[test]
fn network_errors_stay_retryable() {
// 通用网络失败仍应重试(抖动可恢复)——不能被误判成短路。
assert!(!is_non_retryable(&VolcengineASRError::ConnectionFailed(
"dns fail".into()
)));
assert!(!is_non_retryable(&VolcengineASRError::FinalResultTimeout));
}
#[test]
fn classify_401_403_still_auth_rejected() {
// 回归:新增 429 分类不影响既有 401 / 403 → AuthRejected。
assert!(matches!(
classify_connect_error(http_ws_error(401)),
VolcengineASRError::AuthRejected(401)
));
assert!(matches!(
classify_connect_error(http_ws_error(403)),
VolcengineASRError::AuthRejected(403)
));
}
#[test]
fn rate_limited_message_mentions_throttling_not_network() {
// 文案必须明确指向「限流/请求过多」,不是含糊的「网络失败」。
let msg = VolcengineASRError::RateLimited(429).to_string();
assert!(msg.contains("限流") || msg.contains("请求过多"), "文案: {msg}");
assert!(!msg.contains("网络"), "限流文案不应误导为网络失败: {msg}");
}
#[tokio::test]
async fn await_final_result_returns_error_when_final_frame_never_arrives() {
let asr = VolcengineStreamingASR::new(
+67 -9
View File
@@ -46,12 +46,6 @@ pub fn qa_window_dismiss(coord: CoordinatorState<'_>) {
coord.qa_window_dismiss();
}
/// 用户点 📌 / 取消 📌。pinned=true 时浮窗不会自动隐藏。
#[tauri::command]
pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) {
coord.qa_window_pin(pinned);
}
/// 移动端 QA 面板录音按钮:Idle -> begin_qa_session,Recording -> end_qa_session。
#[tauri::command]
pub async fn qa_toggle_recording(coord: CoordinatorState<'_>) -> Result<(), String> {
@@ -71,10 +65,60 @@ pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) {
coord.less_computer_window_dismiss();
}
/// 前端按内容测高后回传高度,后端 clamp + bottom-anchored 重新摆放浮窗。
/// 聊天面板(qa / less-computer)请求键盘焦点。
///
/// 两个浮窗都以「不抢前台」方式显示(macOS orderFrontRegardless,从不 makeKey),
/// 窗口不是 key window 时按键根本进不了 webview —— 「点了输入框却打不出字」的根因。
///
/// macOS:窗口已转「非激活 NSPanel」(make_chat_window_panel_macos),makeKeyAndOrderFront
/// 只给面板键盘焦点、**不激活 app**(Spotlight 同款)—— 之前用 window.set_focus() 会激活
/// 整个 app,把主窗口(设置页)一起带到前台,且 frontmost 变成 OpenLess、AX 读不到原 app
/// 选区。其它平台仍走 set_focus。仅允许两个聊天面板窗口调用(与 less_computer_approve
/// 同款收紧)。
#[tauri::command]
pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) {
coord.less_computer_window_resize(height);
pub fn chat_panel_focus_keyboard(window: Window) -> Result<(), String> {
let label = window.label();
if label != "qa" && label != "less-computer" {
return Err("chat_panel_focus_keyboard is only available to chat panels".to_string());
}
#[cfg(target_os = "macos")]
{
use tauri::Manager;
let label = label.to_string();
let app = window.app_handle().clone();
// NSWindow 操作必须在主线程(macOS 26 硬断言);异常兜底防 AppKit raise 穿透。
let _ = window.app_handle().run_on_main_thread(move || {
use objc2::msg_send;
use objc2::runtime::AnyObject;
let Some(w) = app.get_webview_window(&label) else {
return;
};
let Ok(handle) = w.ns_window() else {
log::warn!("[chat-panel] ns_window unavailable; focus skipped");
return;
};
let ns = handle as *mut AnyObject;
if ns.is_null() {
return;
}
// SAFETY: 闭包内只有一次无返回值的 ObjC 消息发送,无需运行 Rust 析构,
// 异常展开跳过闭包帧不破坏内存安全。
let result = unsafe {
objc2::exception::catch(std::panic::AssertUnwindSafe(|| {
let nil: *mut AnyObject = std::ptr::null_mut();
let _: () = msg_send![ns, makeKeyAndOrderFront: nil];
}))
};
if let Err(e) = result {
log::warn!("[chat-panel] makeKeyAndOrderFront raised (caught): {e:?}");
}
});
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
window.set_focus().map_err(|e| e.to_string())
}
}
/// 浮窗打字输入:文字指令直接进入 Less Computer 执行链(跳过录音与 ASR)。
@@ -83,6 +127,20 @@ pub fn less_computer_submit_text(coord: CoordinatorState<'_>, text: String) {
coord.less_computer_submit_text(text);
}
/// 浮窗 mount 时拉取当前会话的事件缓冲(seq 升序)。
///
/// 浮窗首次创建时 webview 冷加载,后端事件(尤其第一条 `user` —— 用户说的话)
/// 先于前端 listener 注册被丢,表现为「AI 在干活但面板上没有我说的话」。前端
/// mount 后先注册 listener 再调本命令重放积压,按 seq 去重衔接实时流。
/// 会话内容敏感,仅允许 less-computer 窗口调用(与 less_computer_approve 同款收紧)。
#[tauri::command]
pub fn less_computer_sync(window: Window) -> Result<Vec<serde_json::Value>, String> {
if window.label() != "less-computer" {
return Err("sync can only be requested from the Less Computer window".to_string());
}
Ok(crate::coordinator::less_computer_event_backlog())
}
/// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。
///
/// 安全:审批 UI 渲染在 less-computer 窗口(LessComputerPanel),故仅允许该窗口提交,
+4 -15
View File
@@ -77,6 +77,9 @@ use hotkey_loops::*;
use polish_flow::*;
use qa_session::*;
// less_computer_sync 命令的数据源(浮窗 webview 冷加载竞态补偿,见 dictation.rs)。
pub(crate) use dictation::less_computer_event_backlog;
pub(super) fn qa_event_target() -> &'static str {
#[cfg(target_os = "android")]
{
@@ -1079,11 +1082,6 @@ impl Coordinator {
close_qa_panel(&self.inner);
}
/// 用户点 📌 切换 pinned 状态。pinned=true 时浮窗不自动隐藏。
pub fn qa_window_pin(&self, pinned: bool) {
self.inner.qa_state.lock().pinned = pinned;
log::info!("[coord] QA window pinned={pinned}");
}
/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗:隐藏窗口 + 结束连续对话
/// (下次说话开新会话,不再 --continue 续旧上下文)。
@@ -1097,13 +1095,6 @@ impl Coordinator {
}
}
/// 前端按内容测高后回传,后端 clamp + bottom-anchored 重新摆放 Less Computer 浮窗。
pub fn less_computer_window_resize(&self, height: f64) {
if let Some(app) = self.inner.app.lock().clone() {
crate::resize_less_computer_window(&app, height);
}
}
/// 内联审批卡的 Approve / Deny 回执:解析等待中的 token。
pub fn less_computer_approve(&self, token: &str, approved: bool) {
dictation::resolve_less_computer_approval(token, approved);
@@ -1828,9 +1819,7 @@ pub(super) fn insert_via_non_tsf_fallback(
_paste_shortcut: PasteShortcut,
) -> InsertStatus {
let prefs = inner.prefs.get();
let sendinput_options = crate::unicode_keystroke::WindowsSendInputOptions {
newline_mode: prefs.windows_sendinput_newline_mode,
};
let sendinput_options = dictation::windows_sendinput_options_from_prefs(&prefs);
let status = finish_non_tsf_insertion_fallback(
|| inner
.inserter
@@ -66,13 +66,114 @@ pub(super) fn resolve_less_computer_approval(token: &str, approved: bool) {
}
}
/// Less Computer 事件缓冲:浮窗首次创建时 webview 冷加载需要数百毫秒,此时后端
/// emit 的事件(尤其第一条 `user` —— 用户说出的那句话)会先于前端 listener 注册
/// 被丢弃,表现为「AI 在干活、但面板上没有我说的话」。这里按单调 seq 缓存当前
/// 会话的全部事件,前端 mount 后调 `less_computer_sync` 全量重放,实时流按 seq
/// 去重衔接。fresh=true 的 user 事件 = 新会话,清空重来(seq 不回卷,去重不混淆)。
/// 容量上限防极端长会话无界增长(超限丢最旧 —— 重放的意义在冷启动窗口,尾部足够)。
const LESS_COMPUTER_EVENT_LOG_CAP: usize = 2048;
struct LessComputerEventLog {
next_seq: u64,
events: std::collections::VecDeque<serde_json::Value>,
}
static LESS_COMPUTER_EVENT_LOG: std::sync::OnceLock<std::sync::Mutex<LessComputerEventLog>> =
std::sync::OnceLock::new();
fn less_computer_event_log() -> &'static std::sync::Mutex<LessComputerEventLog> {
LESS_COMPUTER_EVENT_LOG.get_or_init(|| {
std::sync::Mutex::new(LessComputerEventLog {
next_seq: 0,
events: std::collections::VecDeque::new(),
})
})
}
/// 纯逻辑:给 payload 编 seq 并写入缓冲(fresh user 先清空,超限丢最旧)。
fn log_less_computer_event(log: &mut LessComputerEventLog, payload: &mut serde_json::Value) {
let fresh_user = payload.get("kind").and_then(|k| k.as_str()) == Some("user")
&& payload.get("fresh").and_then(|f| f.as_bool()) == Some(true);
if fresh_user {
log.events.clear();
}
log.next_seq += 1;
payload["seq"] = serde_json::json!(log.next_seq);
log.events.push_back(payload.clone());
while log.events.len() > LESS_COMPUTER_EVENT_LOG_CAP {
log.events.pop_front();
}
}
/// `less_computer_sync` 命令的数据源:当前会话已发生的事件(seq 升序)。
pub(crate) fn less_computer_event_backlog() -> Vec<serde_json::Value> {
less_computer_event_log()
.lock()
.map(|log| log.events.iter().cloned().collect())
.unwrap_or_default()
}
/// 往 Less Computer 浮窗发一条事件(macOS only;前端按 `kind` 渲染聊天结构)。
fn emit_less_computer(inner: &Arc<Inner>, payload: serde_json::Value) {
/// 每条事件先记入缓冲并带上 seq,再实时 emit —— 锁中毒时跳过缓冲照常 emit
/// (无 seq 事件前端无条件应用,退化为修复前行为而不是丢事件)。
fn emit_less_computer(inner: &Arc<Inner>, mut payload: serde_json::Value) {
if let Ok(mut log) = less_computer_event_log().lock() {
log_less_computer_event(&mut log, &mut payload);
}
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit_to("less-computer", LESS_COMPUTER_EVENT, payload);
}
}
#[cfg(test)]
mod less_computer_event_log_tests {
use super::{log_less_computer_event, LessComputerEventLog, LESS_COMPUTER_EVENT_LOG_CAP};
fn new_log() -> LessComputerEventLog {
LessComputerEventLog {
next_seq: 0,
events: std::collections::VecDeque::new(),
}
}
#[test]
fn assigns_monotonic_seq_and_clears_on_fresh_user() {
let mut log = new_log();
let mut e1 = serde_json::json!({"kind":"user","text":"第一句","fresh":true});
let mut e2 = serde_json::json!({"kind":"delta","text":"好的"});
log_less_computer_event(&mut log, &mut e1);
log_less_computer_event(&mut log, &mut e2);
assert_eq!(e1["seq"], 1);
assert_eq!(e2["seq"], 2);
assert_eq!(log.events.len(), 2);
// fresh=true 开新会话:缓冲清空,seq 继续单调(前端按 seq 去重不回卷)。
let mut e3 = serde_json::json!({"kind":"user","text":"新会话","fresh":true});
log_less_computer_event(&mut log, &mut e3);
assert_eq!(log.events.len(), 1);
assert_eq!(e3["seq"], 3);
// 追加轮次(fresh=false / 缺省)不清空。
let mut e4 = serde_json::json!({"kind":"user","text":"追加","fresh":false});
log_less_computer_event(&mut log, &mut e4);
assert_eq!(log.events.len(), 2);
assert_eq!(log.events.front().unwrap()["seq"], 3);
}
#[test]
fn caps_backlog_dropping_oldest() {
let mut log = new_log();
for i in 0..(LESS_COMPUTER_EVENT_LOG_CAP + 5) {
let mut e = serde_json::json!({"kind":"delta","text":i.to_string()});
log_less_computer_event(&mut log, &mut e);
}
assert_eq!(log.events.len(), LESS_COMPUTER_EVENT_LOG_CAP);
// 丢最旧:队首是第 6 条(seq 从 1 起)。
assert_eq!(log.events.front().unwrap()["seq"], 6);
}
}
/// 跑流式润色路径(opt-in,跨平台)。
///
/// 平台差异:
@@ -344,7 +445,7 @@ async fn run_streaming_polish(
}
#[cfg(target_os = "windows")]
fn windows_sendinput_options_from_prefs(
pub(super) fn windows_sendinput_options_from_prefs(
prefs: &crate::types::UserPreferences,
) -> crate::unicode_keystroke::WindowsSendInputOptions {
crate::unicode_keystroke::WindowsSendInputOptions {
@@ -579,7 +579,6 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
.ok();
#[cfg(target_os = "linux")]
sync_custom_dictation_to_plugin(&inner);
attempts = 0;
return;
}
Err(e) => {
@@ -29,8 +29,6 @@ pub(super) struct QaSessionState {
pub(super) qa_focus_target: Option<usize>,
/// 用于忽略迟到的 RMS / runtime error。
pub(super) session_id: SessionId,
/// QA 浮窗是否被用户钉住(pinned)。pinned=true 时不自动隐藏。
pub(super) pinned: bool,
/// 浮窗是否对用户可见。Cmd+Shift+; 边沿 toggle 此 flag;
/// 主听写 hotkey(rightOption)边沿来时,看这个 flag 决定是走 QA 还是走 dictation。
/// 详见 issue #118 v2。
@@ -48,7 +46,6 @@ impl Default for QaSessionState {
front_app: None,
qa_focus_target: None,
session_id: initial_session_id(),
pinned: false,
panel_visible: false,
messages: Vec::new(),
}
@@ -124,7 +121,6 @@ pub(super) fn close_qa_panel(inner: &Arc<Inner>) {
{
let mut state = inner.qa_state.lock();
state.panel_visible = false;
state.pinned = false;
state.messages.clear();
state.selection = None;
state.front_app = None;
@@ -155,7 +151,6 @@ mod tests {
assert!(st.selection.is_none());
assert!(st.front_app.is_none());
assert!(st.qa_focus_target.is_none());
assert!(!st.pinned, "新建会话不应处于 pinned");
assert!(!st.panel_visible, "浮窗默认不可见,等用户 toggle");
assert!(st.messages.is_empty(), "新建会话历史必须为空");
}
+11 -3
View File
@@ -603,17 +603,25 @@ mod tests {
#[test]
#[cfg(target_os = "windows")]
fn crlf_sendinput_success_uses_expected_typed_count() {
// 期望值直接取自 expected_sendinput_typed_chars(同一分类真相),而非硬编码 3,
// 这样即便 classify_sendinput_char 的规则合法变更,本用例仍表达「实际==期望→Inserted」。
let text = "a\r\nb";
let status = map_sendinput_type_result(text, Ok(3));
let expected = crate::unicode_keystroke::expected_sendinput_typed_chars(text);
let status = map_sendinput_type_result(text, Ok(expected));
assert_eq!(status, InsertStatus::Inserted);
assert_ne!(text.chars().count(), 3);
// `\r` 被跳过,故期望值必然小于原始 char 数——这正是三处规则若漂移就会出错的点。
assert_ne!(text.chars().count(), expected);
}
#[test]
#[cfg(target_os = "windows")]
fn crlf_sendinput_partial_mismatch_falls_back_to_clipboard() {
// 实际发出的 typed char 数比期望多(把 `\r` 也算进去了)→ 视为部分失败 → 回落剪贴板。
let text = "a\r\nb";
let status = map_sendinput_type_result(text, Ok(text.chars().count()));
let expected = crate::unicode_keystroke::expected_sendinput_typed_chars(text);
let mismatched = text.chars().count();
assert_ne!(mismatched, expected);
let status = map_sendinput_type_result(text, Ok(mismatched));
assert_eq!(status, InsertStatus::CopiedFallback);
}
+111 -83
View File
@@ -102,6 +102,11 @@ const OPENLESS_BUNDLE_ID: &str = "com.openless.app";
/// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition,
/// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。
static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false);
/// 聊天面板退场动画的世代计数:hide 先发 `chat-panel:closing` 让前端播 220ms
/// 退场动画、240ms 后才真正 hide;期间再次 show 会推进世代,作废挂起的 hide。
static QA_PANEL_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static LESS_COMPUTER_PANEL_EPOCH: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
#[cfg(not(mobile))]
static TRAY_MICROPHONE_WATCHER_STOPPING: AtomicBool = AtomicBool::new(false);
#[cfg(not(mobile))]
@@ -230,10 +235,12 @@ macro_rules! app_invoke_handler_desktop {
commands::set_switch_style_hotkey,
commands::set_open_app_hotkey,
commands::qa_window_dismiss,
commands::qa_window_pin,
commands::qa_toggle_recording,
commands::qa_submit_text,
commands::less_computer_window_dismiss,
commands::less_computer_window_resize,
commands::chat_panel_focus_keyboard,
commands::less_computer_submit_text,
commands::less_computer_sync,
commands::less_computer_approve,
commands::validate_combo_hotkey,
commands::set_combo_hotkey,
@@ -360,7 +367,6 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::stop_dictation,
$crate::commands::cancel_dictation,
$crate::commands::qa_window_dismiss,
$crate::commands::qa_window_pin,
$crate::commands::qa_toggle_recording,
$crate::commands::qa_submit_text,
$crate::commands::repolish,
@@ -1516,10 +1522,10 @@ fn wait_for_app_activation<R: Runtime>(app: &AppHandle<R>) {
#[cfg(not(target_os = "macos"))]
fn wait_for_app_activation<R: Runtime>(_app: &AppHandle<R>) {}
/// QA 浮窗的目标尺寸(issue #118)。胶囊默认 220×96 + Dock 80pt + 8pt gap,
/// 算下来 QA 窗口顶部坐标 = h - 80 - 96 - 8 - 280。
const QA_WINDOW_WIDTH: f64 = 380.0;
const QA_WINDOW_HEIGHT: f64 = 440.0;
/// QA 浮窗的目标尺寸(issue #118;统一聊天面板后与 Less Computer 同尺寸)。
/// 窗口固定大小,内容在面板内部的 MessageScroller 里滚动。
const QA_WINDOW_WIDTH: f64 = 420.0;
const QA_WINDOW_HEIGHT: f64 = 540.0;
/// 胶囊与 QA 窗口的间距,与设计稿一致。
const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0;
/// 给 macOS Dock 留的下边距(与 capsule 同源)。
@@ -2060,6 +2066,9 @@ pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind
if let Err(e) = window.show() {
log::warn!("[qa] show failed: {e}");
}
// 作废挂起的退场 hide(快速关-开),并让前端重放入场动画。
QA_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst);
let _ = app.emit_to("qa", "chat-panel:shown", serde_json::json!({}));
let _ = app.emit_to(
"qa",
"qa:state",
@@ -2067,7 +2076,35 @@ pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind
);
}
/// QA 浮窗的拖动修复(macOS)。
/// 聊天面板浮窗(qa / less-computer)转「非激活 NSPanel」(macOS,胶囊同手法)。
///
/// 目的:让面板可以**在 OpenLess 不激活的情况下成为 key window**(Spotlight 同款)。
/// 之前打字焦点走 `window.set_focus()`,它会激活整个 app —— 主窗口(设置页)被
/// 一起带到前台,且 frontmost 变成 OpenLess 自己、AX 再也读不到原 app 的选区。
/// 转成 NonactivatingPanel 后:点输入框 / makeKeyWindow 只给面板键盘焦点,
/// app 保持后台、主窗口不动、frontmost 始终是用户原 app。
/// 顺带 CanJoinAllSpaces + FullScreenAuxiliary:划词常发生在全屏 app 里,面板要能叠上去。
#[cfg(target_os = "macos")]
fn make_chat_window_panel_macos<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, tag: &str) {
use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior;
use tauri_nspanel::WebviewWindowExt;
match window.to_panel() {
Ok(panel) => {
const NS_NONACTIVATING_PANEL_MASK: i32 = 1 << 7;
panel.set_style_mask(NS_NONACTIVATING_PANEL_MASK);
// 浮层级别(NSFloatingWindowLevel):盖普通窗口,不盖菜单栏/胶囊(25)。
panel.set_level(3);
panel.set_collection_behaviour(
NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces,
);
log::info!("[{tag}] converted to nonactivating NSPanel");
}
Err(e) => log::warn!("[{tag}] to_panel failed: {e:?}"),
}
}
/// 聊天面板浮窗(qa / less-computer)的拖动修复(macOS)。
///
/// 配置 `focus: false` 让 Tauri 把窗口创建为 nonactivating panel 风格(避免抢前台 app
/// 焦点)。代价是 AppKit 的 `performWindowDragWithEvent:` 在 nonactivating 窗口上无效,
@@ -2076,16 +2113,16 @@ pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind
/// 解法是把 NSWindow 的 `movableByWindowBackground` 打开——这条路径不依赖窗口是否成为
/// key window,跟 Spotlight / Raycast 的浮窗是同一手法。设一次就够,整个生命周期保持。
#[cfg(target_os = "macos")]
fn make_qa_window_draggable_macos<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) {
fn make_chat_window_draggable_macos<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, tag: &str) {
use objc2::msg_send;
use objc2::runtime::{AnyObject, Bool};
let Ok(handle) = window.ns_window() else {
log::warn!("[qa] ns_window unavailable; drag fix skipped");
log::warn!("[{tag}] ns_window unavailable; drag fix skipped");
return;
};
let ns_window = handle as *mut AnyObject;
if ns_window.is_null() {
log::warn!("[qa] ns_window null; drag fix skipped");
log::warn!("[{tag}] ns_window null; drag fix skipped");
return;
}
// 异常兜底:AppKit 在 drag margins 状态不一致时会从 setMovable 内部 raise
@@ -2100,8 +2137,10 @@ fn make_qa_window_draggable_macos<R: tauri::Runtime>(window: &tauri::WebviewWind
}))
};
match result {
Ok(()) => log::info!("[qa] NSWindow movableByWindowBackground=YES"),
Err(e) => log::error!("[qa] drag setup raised ObjC exception (caught, drag disabled): {e:?}"),
Ok(()) => log::info!("[{tag}] NSWindow movableByWindowBackground=YES"),
Err(e) => {
log::error!("[{tag}] drag setup raised ObjC exception (caught, drag disabled): {e:?}")
}
}
}
@@ -2117,7 +2156,7 @@ fn ensure_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<tauri::Webv
}
let built = WebviewWindowBuilder::new(app, "qa", WebviewUrl::App("index.html?window=qa".into()))
.title("OpenLess QA")
.inner_size(380.0, 440.0)
.inner_size(QA_WINDOW_WIDTH, QA_WINDOW_HEIGHT)
.decorations(false)
.transparent(true)
.shadow(true)
@@ -2137,7 +2176,10 @@ fn ensure_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<tauri::Webv
#[cfg(target_os = "macos")]
{
let w_clone = w.clone();
let _ = app.run_on_main_thread(move || make_qa_window_draggable_macos(&w_clone));
let _ = app.run_on_main_thread(move || {
make_chat_window_panel_macos(&w_clone, "qa");
make_chat_window_draggable_macos(&w_clone, "qa");
});
}
Some(w)
}
@@ -2167,7 +2209,7 @@ fn ensure_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<
WebviewUrl::App("index.html?window=less-computer".into()),
)
.title("OpenLess Less Computer")
.inner_size(400.0, 200.0)
.inner_size(LESS_COMPUTER_WINDOW_WIDTH, LESS_COMPUTER_WINDOW_HEIGHT)
.decorations(false)
.transparent(true)
.shadow(true)
@@ -2179,7 +2221,16 @@ fn ensure_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<
.accept_first_mouse(true)
.build()
{
Ok(w) => Some(w),
Ok(w) => {
// 与 QA 同款:转非激活 NSPanel(打字不激活 app)+ 拖动修复
// (movableByWindowBackground;必须主线程,见 make_chat_window_draggable_macos)。
let w_clone = w.clone();
let _ = app.run_on_main_thread(move || {
make_chat_window_panel_macos(&w_clone, "less-computer");
make_chat_window_draggable_macos(&w_clone, "less-computer");
});
Some(w)
}
Err(e) => {
log::warn!("[less-computer] lazy window create failed: {e}");
None
@@ -2222,6 +2273,32 @@ fn ensure_less_computer_glow_window<R: tauri::Runtime>(
}
}
/// 带退场动画地隐藏聊天面板:先发 `chat-panel:closing` 让前端播退场动画,
/// 240ms 后真正 hide。期间再次 show(epoch 前进)则作废本次挂起的 hide ——
/// 避免「关的动画还没放完用户又唤起 → 窗口被旧定时器藏掉」。
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn hide_chat_window_animated<R: tauri::Runtime>(
app: &AppHandle<R>,
label: &str,
epoch: &'static std::sync::atomic::AtomicU64,
) {
let Some(window) = app.get_webview_window(label) else {
return;
};
if !window.is_visible().unwrap_or(false) {
let _ = window.hide();
return;
}
let token = epoch.fetch_add(1, Ordering::SeqCst) + 1;
let _ = app.emit_to(label, "chat-panel:closing", serde_json::json!({}));
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(240)).await;
if epoch.load(Ordering::SeqCst) == token {
let _ = window.hide();
}
});
}
/// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。
pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
#[cfg(target_os = "android")]
@@ -2230,9 +2307,8 @@ pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
return;
}
if let Some(window) = app.get_webview_window("qa") {
let _ = window.hide();
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
hide_chat_window_animated(app, "qa", &QA_PANEL_EPOCH);
}
// ───────────────────────── Less Computer 浮窗 ─────────────────────────
@@ -2242,21 +2318,17 @@ pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
// 不注册热键、前端 detectOS 不渲染入口),所以这些窗口操作全部 `#[cfg(macos)]`,
// 其它平台是 no-op,避免在非目标平台动 NSWindow / 弹一个空浮窗。
/// Less Computer 浮窗宽度(高度由前端按内容自适应,经 `less_computer_window_resize`
/// 回传,Rust 端按 bottom-anchored 重新摆放,让内容增长向上撑开)。
/// Less Computer 浮窗尺寸:与 QA 同款「统一聊天面板」固定大小 —— 窗口出现即
/// 定死,内容只在面板内部的 MessageScroller 里滚动,不再按内容自适应缩放窗口。
#[cfg(target_os = "macos")]
const LESS_COMPUTER_WINDOW_WIDTH: f64 = 400.0;
const LESS_COMPUTER_WINDOW_WIDTH: f64 = 420.0;
#[cfg(target_os = "macos")]
const LESS_COMPUTER_WINDOW_MIN_HEIGHT: f64 = 120.0;
#[cfg(target_os = "macos")]
const LESS_COMPUTER_WINDOW_MAX_HEIGHT: f64 = 520.0;
const LESS_COMPUTER_WINDOW_HEIGHT: f64 = 540.0;
/// 把 Less Computer 浮窗按给定高度(clamp 到 [min,max])摆到屏幕底部居中、
/// 紧贴胶囊上方。bottom 对齐胶囊顶部,所以高度变化时窗口向上生长。
/// 把 Less Computer 浮窗(固定尺寸)摆到屏幕底部居中、紧贴胶囊上方。
#[cfg(target_os = "macos")]
fn position_less_computer_window<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
height: f64,
) -> tauri::Result<()> {
let monitor = match window.current_monitor()? {
Some(m) => m,
@@ -2266,18 +2338,17 @@ fn position_less_computer_window<R: tauri::Runtime>(
let size = monitor.size();
let pos = monitor.position();
let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale);
let height = height.clamp(
LESS_COMPUTER_WINDOW_MIN_HEIGHT,
LESS_COMPUTER_WINDOW_MAX_HEIGHT,
);
let capsule_height = capsule_height_for_qa();
let (x, y) = bottom_center_position(
frame,
LESS_COMPUTER_WINDOW_WIDTH,
height,
LESS_COMPUTER_WINDOW_HEIGHT,
DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE,
);
window.set_size(tauri::LogicalSize::new(LESS_COMPUTER_WINDOW_WIDTH, height))?;
window.set_size(tauri::LogicalSize::new(
LESS_COMPUTER_WINDOW_WIDTH,
LESS_COMPUTER_WINDOW_HEIGHT,
))?;
window.set_position(LogicalPosition::new(x, y))?;
Ok(())
}
@@ -2289,7 +2360,7 @@ pub(crate) fn show_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) {
log::info!("[less-computer] show 跳过:窗口不存在");
return;
};
if let Err(e) = position_less_computer_window(&window, LESS_COMPUTER_WINDOW_MIN_HEIGHT) {
if let Err(e) = position_less_computer_window(&window) {
log::warn!("[less-computer] position before show failed: {e}");
}
let window_clone = window.clone();
@@ -2314,6 +2385,9 @@ pub(crate) fn show_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) {
}
}
});
// 作废挂起的退场 hide(快速关-开),并让前端重放入场动画。
LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst);
let _ = app.emit_to("less-computer", "chat-panel:shown", serde_json::json!({}));
}
#[cfg(not(target_os = "macos"))]
@@ -2322,9 +2396,7 @@ pub(crate) fn show_less_computer_window<R: tauri::Runtime>(_app: &AppHandle<R>)
/// 隐藏 Less Computer 浮窗。供 dismiss 命令 / session 收尾共用。
#[cfg(target_os = "macos")]
pub(crate) fn hide_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window("less-computer") {
let _ = window.hide();
}
hide_chat_window_animated(app, "less-computer", &LESS_COMPUTER_PANEL_EPOCH);
}
#[cfg(not(target_os = "macos"))]
@@ -2404,50 +2476,6 @@ pub(crate) fn hide_less_computer_glow<R: tauri::Runtime>(app: &AppHandle<R>) {
#[cfg(not(target_os = "macos"))]
pub(crate) fn hide_less_computer_glow<R: tauri::Runtime>(_app: &AppHandle<R>) {}
/// 前端按内容测高后回传。以「当前窗口底边」为锚向上生长——只改高度、保住用户拖动后的位置,
/// 不再重新居中(否则一改内容就把拖走的框拉回屏幕底部中间)。`macos` 专用。
#[cfg(target_os = "macos")]
pub(crate) fn resize_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>, height: f64) {
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 monitor_top = window
.current_monitor()
.ok()
.flatten()
.map(|m| {
let p = m.position();
let s = m.size();
logical_monitor_frame(p.x, p.y, s.width, s.height, m.scale_factor()).y
})
.unwrap_or(f64::NEG_INFINITY);
let new_y = (bottom - height).max(monitor_top);
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}");
}
}
}
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn resize_less_computer_window<R: tauri::Runtime>(_app: &AppHandle<R>, _height: f64) {}
/// 抓完选区后把焦点重新交回 QA 浮窗(Windows focus-dance 下半场)。begin_qa_session
/// 在 capture_selection 跑完时调;非 Windows 平台是 no-op。issue #466。
#[cfg(target_os = "windows")]
@@ -15,7 +15,6 @@ use crate::types::ShortcutBinding;
static ACTIVE_MONITOR: OnceLock<RwLock<Option<ActiveSideCombo>>> = OnceLock::new();
struct ActiveSideCombo {
binding: ShortcutBinding,
tx: Sender<ComboHotkeyEvent>,
state: Mutex<SideAwareComboState>,
}
@@ -111,12 +110,29 @@ impl SideAwareComboState {
return None;
}
if pressed {
if self.modifiers_match() && !self.combo_active {
self.combo_active = true;
return Some(ComboHotkeyEvent::Pressed);
if self.modifiers_match() {
// `modifiers_match()` is the authoritative activation gate. If
// `combo_active` is still true here, the previous `Released` was
// dropped by the OS: we must NOT emit a second `Pressed` (that
// would break the pairing invariant), so treat the flag as
// already reflecting an active combo and swallow this edge.
if !self.combo_active {
self.combo_active = true;
return Some(ComboHotkeyEvent::Pressed);
}
return None;
}
// Modifiers no longer match — this is the single authoritative reset
// condition. If `combo_active` is stuck true (a modifier release was
// dropped so no `Released` was ever emitted), self-heal by emitting
// the terminal `Released` now so the recording latch cannot stick.
if self.combo_active {
self.combo_active = false;
return Some(ComboHotkeyEvent::Released);
}
return None;
}
// Primary key up is the absolute termination signal for the combo.
if self.combo_active {
self.combo_active = false;
return Some(ComboHotkeyEvent::Released);
@@ -126,6 +142,8 @@ impl SideAwareComboState {
fn on_modifier_release(&mut self, side: SideModifier) -> Option<ComboHotkeyEvent> {
self.set_side(side, false);
// Modifiers no longer matching is the authoritative reset condition:
// once the required side-modifier set is broken, the combo is over.
if self.combo_active && !self.modifiers_match() {
self.combo_active = false;
return Some(ComboHotkeyEvent::Released);
@@ -153,6 +171,20 @@ impl SideAwareComboMonitor {
binding: ShortcutBinding,
tx: Sender<ComboHotkeyEvent>,
) -> Result<Self, crate::combo_hotkey::ComboHotkeyError> {
// Linux has no side-aware platform dispatch (no CGEventTap / WH_KEYBOARD_LL
// equivalent wired here). Accepting the binding would leave the user with a
// "successfully configured" combo that silently never fires. Reject up front
// so the caller can surface an actionable error instead.
#[cfg(target_os = "linux")]
{
let _ = (&binding, &tx);
return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier(
"侧向修饰键组合键在 Linux 暂不支持".into(),
));
}
#[cfg(not(target_os = "linux"))]
{
if binding.modifiers.is_empty()
|| binding
.modifiers
@@ -170,11 +202,11 @@ impl SideAwareComboMonitor {
let slot = ACTIVE_MONITOR.get_or_init(|| RwLock::new(None));
let mut guard = slot.write().expect("side combo monitor lock poisoned");
*guard = Some(ActiveSideCombo {
binding: binding.clone(),
tx,
state: Mutex::new(SideAwareComboState::new(binding)),
});
Ok(Self)
}
}
}
@@ -412,10 +444,28 @@ pub mod platform {
const COMBINED_SESSION: c_int = 1;
pub fn dispatch_keycode(keycode: i64, flags_changed: bool, _flags: u64, pressed: bool) {
// NSEventModifierFlags device-independent class bits (also match the CGEventFlags
// masks used by hotkey.rs `handle_flags_changed`). One bit per modifier *class* —
// it does NOT encode which side (left/right) is held.
const FLAG_MASK_SHIFT: u64 = 1 << 17; // 0x0002_0000
const FLAG_MASK_CONTROL: u64 = 1 << 18; // 0x0004_0000
const FLAG_MASK_ALTERNATE: u64 = 1 << 19; // 0x0008_0000 (Option/Alt)
const FLAG_MASK_COMMAND: u64 = 1 << 20; // 0x0010_0000
pub fn dispatch_keycode(keycode: i64, flags_changed: bool, flags: u64, pressed: bool) {
if flags_changed {
if let Some(side) = side_from_keycode(keycode) {
handle_side_modifier(side, keycode_is_down(keycode));
// The event-carried `flags` are race-free (they describe the state
// *at* this event); `CGEventSourceKeyState` is a separate query that
// may observe a later state. Prefer flags where they are decisive.
let down = match resolve_side_pressed_from_flags(keycode, flags) {
Some(down) => down,
// Class bit is set: some key in this modifier class is down, but a
// single flags bit cannot tell left from right. Fall back to
// `CGEventSourceKeyState` (Apple's standard way to resolve side).
None => keycode_is_down(keycode),
};
handle_side_modifier(side, down);
}
return;
}
@@ -425,6 +475,38 @@ pub mod platform {
}
}
/// Modifier-class flag bit for a side-modifier keycode, or `None` if the keycode
/// is not a known side modifier.
fn class_mask_for_keycode(keycode: i64) -> Option<u64> {
match keycode {
55 | 54 => Some(FLAG_MASK_COMMAND), // Cmd left / right
59 | 62 => Some(FLAG_MASK_CONTROL), // Ctrl left / right
58 | 61 => Some(FLAG_MASK_ALTERNATE), // Alt/Option left / right
56 | 60 => Some(FLAG_MASK_SHIFT), // Shift left / right
_ => None,
}
}
/// Pure race-free classifier for a FLAGS_CHANGED side-modifier event.
///
/// Returns:
/// - `Some(false)` when the event's `flags` show the modifier *class* fully
/// released (class bit clear). This is the dangerous case to get wrong —
/// a stuck-down misread leaves the combo latched — and the flags bit makes
/// it unambiguous with no query race. Force the side up.
/// - `None` when the class bit is set: at least one side of this class is held,
/// but a single class bit cannot distinguish left vs right, so the caller
/// must query `CGEventSourceKeyState` to resolve the specific side.
/// - `None` for non-side keycodes (caller will have already filtered these).
pub(crate) fn resolve_side_pressed_from_flags(keycode: i64, flags: u64) -> Option<bool> {
let mask = class_mask_for_keycode(keycode)?;
if flags & mask == 0 {
Some(false)
} else {
None
}
}
fn keycode_is_down(keycode: i64) -> bool {
unsafe { CGEventSourceKeyState(COMBINED_SESSION, keycode as u16) }
}
@@ -565,4 +647,163 @@ mod tests {
assert_eq!(side_from_keycode(56), Some(SideModifier::ShiftLeft));
assert_eq!(side_from_keycode(60), Some(SideModifier::ShiftRight));
}
// ---- Fix 1: event-pairing self-healing (Pressed always paired with Released) ----
fn cmd_left_d_state() -> SideAwareComboState {
SideAwareComboState::new(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
})
}
#[test]
fn normal_press_then_release_is_paired() {
let mut state = cmd_left_d_state();
state.set_side(SideModifier::CmdLeft, true);
assert_eq!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed));
// Primary key up terminates the combo with exactly one Released.
assert_eq!(
state.on_primary("D", false),
Some(ComboHotkeyEvent::Released)
);
// No trailing events; a second key-up must not emit anything.
assert_eq!(state.on_primary("D", false), None);
assert!(!state.combo_active);
}
#[test]
fn modifier_release_after_press_emits_paired_released() {
let mut state = cmd_left_d_state();
state.set_side(SideModifier::CmdLeft, true);
assert_eq!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed));
// Modifier lifts while primary is still down -> combo terminates once.
assert_eq!(
state.on_modifier_release(SideModifier::CmdLeft),
Some(ComboHotkeyEvent::Released)
);
assert!(!state.combo_active);
// A now-orphaned primary key-up must NOT emit a second Released.
assert_eq!(state.on_primary("D", false), None);
}
#[test]
fn dropped_modifier_release_is_recovered_on_primary_up() {
// Simulate the OS dropping the modifier-up event: combo_active stays true
// and modifiers still "match" from the state's perspective. The primary
// key-up (absolute termination) must still emit the paired Released.
let mut state = cmd_left_d_state();
state.set_side(SideModifier::CmdLeft, true);
assert_eq!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed));
// Modifier physically released but the release event never arrived, so the
// side flag is still set here. Primary up is the fallback terminator.
assert_eq!(
state.on_primary("D", false),
Some(ComboHotkeyEvent::Released)
);
assert!(!state.combo_active);
}
#[test]
fn stale_combo_active_reset_when_modifiers_stop_matching() {
// Reproduce a stuck latch: a prior Released was lost so combo_active is true,
// yet the required modifier is no longer held (modifiers_match() == false).
// The next primary-down must self-heal by emitting the terminal Released
// (NOT swallow it, and NOT emit a second Pressed) so recording can't stick.
let mut state = cmd_left_d_state();
state.combo_active = true; // stale flag from a dropped Released
assert!(!state.modifiers_match()); // cmd-left is not held
let evt = state.on_primary("D", true);
assert_eq!(evt, Some(ComboHotkeyEvent::Released));
assert!(!state.combo_active);
}
#[test]
fn dropped_release_then_fresh_full_press_triggers_again() {
// End-to-end: after a lost Released, a subsequent *complete* press cycle
// must be able to fire a fresh Pressed. Guards against the combo becoming
// permanently unrepeatable (the historical #545/#468 stuck-latch class).
let mut state = cmd_left_d_state();
state.combo_active = true; // leftover from a dropped Released
// Releasing the required side-modifier breaks the match, so the stale latch
// self-heals by emitting the terminal Released here (pairing the Pressed whose
// Released was dropped). Either way combo_active must end up cleared.
assert_eq!(
state.on_modifier_release(SideModifier::CmdLeft),
Some(ComboHotkeyEvent::Released)
);
assert!(!state.combo_active);
// Fresh, clean press cycle now behaves normally.
state.set_side(SideModifier::CmdLeft, true);
assert_eq!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed));
assert_eq!(
state.on_primary("D", false),
Some(ComboHotkeyEvent::Released)
);
}
#[test]
fn stale_combo_active_still_matching_does_not_double_press() {
// If the flag is stale-true but modifiers currently match (edge case where a
// Released was lost but the modifier happens to be held again), a primary-down
// must NOT emit a second Pressed — that would break the pairing invariant.
let mut state = cmd_left_d_state();
state.set_side(SideModifier::CmdLeft, true);
state.combo_active = true; // pretend previous Released was dropped
assert!(state.modifiers_match());
assert_eq!(state.on_primary("D", true), None);
// The real terminator (primary up) still yields exactly one Released.
assert_eq!(
state.on_primary("D", false),
Some(ComboHotkeyEvent::Released)
);
}
// ---- Fix 2: macOS race-free FLAGS_CHANGED side classification ----
#[cfg(target_os = "macos")]
#[test]
fn flags_class_bit_clear_forces_side_released() {
use crate::side_aware_combo::platform::resolve_side_pressed_from_flags;
// Command class bit clear -> Cmd keys (55 left / 54 right) are decisively up.
assert_eq!(resolve_side_pressed_from_flags(55, 0), Some(false));
assert_eq!(resolve_side_pressed_from_flags(54, 0), Some(false));
// Shift bit set but Command bit clear -> a Cmd keycode is still decisively up.
let shift_only: u64 = 1 << 17;
assert_eq!(resolve_side_pressed_from_flags(55, shift_only), Some(false));
}
#[cfg(target_os = "macos")]
#[test]
fn flags_class_bit_set_defers_to_query() {
use crate::side_aware_combo::platform::resolve_side_pressed_from_flags;
// Command class bit set -> cannot tell left from right; defer (None) so the
// caller resolves the specific side via CGEventSourceKeyState.
let cmd_bit: u64 = 1 << 20;
assert_eq!(resolve_side_pressed_from_flags(55, cmd_bit), None);
assert_eq!(resolve_side_pressed_from_flags(54, cmd_bit), None);
// Shift keycodes with the Shift bit set likewise defer.
let shift_bit: u64 = 1 << 17;
assert_eq!(resolve_side_pressed_from_flags(56, shift_bit), None);
assert_eq!(resolve_side_pressed_from_flags(60, shift_bit), None);
}
#[cfg(target_os = "macos")]
#[test]
fn flags_classifier_maps_each_modifier_class() {
use crate::side_aware_combo::platform::resolve_side_pressed_from_flags;
// Each side keycode keys off its own class bit; an unrelated set bit does not
// count as "held" for that class, so the side is forced released.
let ctrl_bit: u64 = 1 << 18;
let alt_bit: u64 = 1 << 19;
// Ctrl (59/62) held -> defer; Alt (58/61) with only Ctrl set -> forced up.
assert_eq!(resolve_side_pressed_from_flags(59, ctrl_bit), None);
assert_eq!(resolve_side_pressed_from_flags(58, ctrl_bit), Some(false));
assert_eq!(resolve_side_pressed_from_flags(58, alt_bit), None);
// Non-side keycode (e.g. 'D' == 2) is never classified here.
assert_eq!(resolve_side_pressed_from_flags(2, alt_bit), None);
assert_eq!(resolve_side_pressed_from_flags(2, 0), None);
}
}
@@ -355,35 +355,35 @@ mod windows_impl {
let mut sent_in_chunk = 0usize;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\r' {
continue;
}
if ch == '\n' {
if let Err(e) = send_newline(options.newline_mode) {
return Err(partial_or_original(typed_chars, e));
}
typed_chars += 1;
sent_in_chunk += 1;
} else if ch == '\t' {
if let Err(e) = press_vk(VK_TAB) {
return Err(partial_or_original(typed_chars, e));
}
typed_chars += 1;
sent_in_chunk += 1;
} else {
let mut buf = [0u16; 2];
for unit in ch.encode_utf16(&mut buf) {
if let Err(e) = send_utf16_unit(*unit, false) {
return Err(partial_or_original(typed_chars, e));
}
if let Err(e) = send_utf16_unit(*unit, true) {
// 分类与计数复用 `classify_sendinput_char` / `sendinput_char_is_typed`——与
// `expected_sendinput_typed_chars` 同一真相,避免规则漂移导致成功的 SendInput
// 被误判为回落。
match super::classify_sendinput_char(ch) {
super::SendInputCharKind::Skip => continue,
super::SendInputCharKind::Newline => {
if let Err(e) = send_newline(options.newline_mode) {
return Err(partial_or_original(typed_chars, e));
}
}
typed_chars += 1;
sent_in_chunk += 1;
super::SendInputCharKind::Tab => {
if let Err(e) = press_vk(VK_TAB) {
return Err(partial_or_original(typed_chars, e));
}
}
super::SendInputCharKind::Unicode => {
let mut buf = [0u16; 2];
for unit in ch.encode_utf16(&mut buf) {
if let Err(e) = send_utf16_unit(*unit, false) {
return Err(partial_or_original(typed_chars, e));
}
if let Err(e) = send_utf16_unit(*unit, true) {
return Err(partial_or_original(typed_chars, e));
}
}
}
}
typed_chars += 1;
sent_in_chunk += 1;
if sent_in_chunk >= SENDINPUT_CHUNK_CHARS && chars.peek().is_some() {
std::thread::sleep(SENDINPUT_CHUNK_DELAY);
@@ -497,26 +497,41 @@ mod windows_impl {
) -> Result<(), TisError> {
Ok(())
}
}
#[cfg(test)]
pub(super) fn classify_sendinput_char(ch: char) -> SendInputCharKind {
match ch {
'\r' => SendInputCharKind::Skip,
'\n' => SendInputCharKind::Newline,
'\t' => SendInputCharKind::Tab,
_ => SendInputCharKind::Unicode,
}
}
/// SendInput 单字符分类的唯一真相。`type_unicode_chunk_with_options` 的实际打字路径与
/// `expected_sendinput_typed_chars`(用于校验实际发出的 typed char 数)都复用它,避免三处
/// 独立的字符规则手工同步——一旦漏改,一次成功的 SendInput 会被 `map_sendinput_type_result`
/// 误判成 `CopiedFallback`,在已打字的基础上又把整段复制到剪贴板,用户 Ctrl+V 看到重复。
#[cfg(target_os = "windows")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SendInputCharKind {
/// `\r`:跳过(CRLF 只产生一次换行),不计入 typed char。
Skip,
/// `\n`:按换行发出(模式由 `WindowsSendInputOptions::newline_mode` 决定)。
Newline,
/// `\t`:按 Tab 键发出。
Tab,
/// 其余字符:按 UTF-16 Unicode 事件逐 code unit 发出。
Unicode,
}
#[cfg(test)]
pub(super) enum SendInputCharKind {
Skip,
Newline,
Tab,
Unicode,
#[cfg(target_os = "windows")]
pub(crate) fn classify_sendinput_char(ch: char) -> SendInputCharKind {
match ch {
'\r' => SendInputCharKind::Skip,
'\n' => SendInputCharKind::Newline,
'\t' => SendInputCharKind::Tab,
_ => SendInputCharKind::Unicode,
}
}
/// 该字符是否计入「已发出的 typed char」。`Skip`(`\r`)不计入,其余都计入。
#[cfg(target_os = "windows")]
pub(crate) fn sendinput_char_is_typed(kind: SendInputCharKind) -> bool {
!matches!(kind, SendInputCharKind::Skip)
}
// ═══════════════════════════════════════════════════════════════════════════
// Linux 实现(实验性)
// ═══════════════════════════════════════════════════════════════════════════
@@ -591,6 +606,7 @@ mod tests {
TypeError::EnigoText("fail".into())
}
#[cfg(target_os = "windows")]
#[test]
fn expected_sendinput_typed_chars_skips_carriage_return() {
assert_eq!(super::expected_sendinput_typed_chars("a\r\nb"), 3);
@@ -600,7 +616,10 @@ mod tests {
#[cfg(target_os = "windows")]
mod windows_sendinput_char_tests {
use super::super::windows_impl::{classify_sendinput_char, SendInputCharKind};
use super::super::{
classify_sendinput_char, expected_sendinput_typed_chars, sendinput_char_is_typed,
SendInputCharKind,
};
#[test]
fn classify_skips_carriage_return() {
@@ -626,13 +645,44 @@ mod tests {
SendInputCharKind::Unicode
));
}
/// 只有 `Skip`(`\r`)不计入 typed char,其余三类都计入。这是
/// `expected_sendinput_typed_chars` 与实际打字循环 `typed_chars += 1` 之间保持一致
/// 的核心不变量。
#[test]
fn only_carriage_return_is_not_counted() {
assert!(!sendinput_char_is_typed(SendInputCharKind::Skip));
assert!(sendinput_char_is_typed(SendInputCharKind::Newline));
assert!(sendinput_char_is_typed(SendInputCharKind::Tab));
assert!(sendinput_char_is_typed(SendInputCharKind::Unicode));
}
/// 期望计数必须与「逐字符分类后计入的数量」逐字节一致——即 expected 复用了同一分类
/// 真相。若二者用不同规则表达,成功的 SendInput 会被 `map_sendinput_type_result`
/// 误判为 `CopiedFallback`(重复粘贴)。
#[test]
fn expected_count_matches_per_char_classification() {
for sample in ["a\r\nb", "hello", "\r\r\n", "行1\n\t行2", ""] {
let manual = sample
.chars()
.filter(|ch| sendinput_char_is_typed(classify_sendinput_char(*ch)))
.count();
assert_eq!(expected_sendinput_typed_chars(sample), manual, "{sample:?}");
}
}
}
}
/// Windows SendInput 路径上 `type_unicode_chunk` 计入的 typed char 数。
/// `\r` 会被跳过(CRLF 只产生一次换行),因此不能与 `text.chars().count()` 直接比较。
///
/// 复用 `classify_sendinput_char` / `sendinput_char_is_typed`——与实际打字路径同一真相,
/// 保证 `map_sendinput_type_result` 的期望值与真正发出的 typed char 数永远一致。
#[cfg(target_os = "windows")]
pub fn expected_sendinput_typed_chars(text: &str) -> usize {
text.chars().filter(|ch| *ch != '\r').count()
text.chars()
.filter(|ch| sendinput_char_is_typed(classify_sendinput_char(*ch)))
.count()
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -154,20 +154,39 @@ pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult<bool> {
))
}
/// TSF IME 未装(或注册损坏)时「键盘列表可见性」偏好的短路结果。
///
/// 返回 `Some(result)` 表示无需触碰注册表即可结束;`None` 表示已安装,需要走真正的
/// `EnableLanguageProfile` 变更。抽成纯函数,使「未安装」这一分支能在任意平台上被测试
/// 覆盖到(`apply_windows_openless_keyboard_list_pref` 依赖 Windows 注册表,macOS/CI
/// 无法命中其内部分支)。
///
/// 关键语义:TSF IME 未安装时,键盘列表里根本没有 OpenLess 条目——
/// - `desired == false`(不显示)是天然已满足的空操作;
/// - `desired == true`(显示)也只能是 no-op(没东西可启用)。
///
/// 两支都必须是 `Ok(())`。此前「不显示 + 未安装」错误地返回 `Err`,经 settings.rs 的
/// `apply_keyboard_list(&prefs)?` 传播,导致整个设置保存事务回滚(用户勾「仅 SendInput
/// 插入」+「不在键盘列表显示」且未装 TSF IME 时,之后任何设置都存不进)。
fn keyboard_list_pref_short_circuit(
install_state: WindowsImeInstallState,
_desired: bool,
) -> Option<Result<(), String>> {
if install_state == WindowsImeInstallState::Installed {
None
} else {
Some(Ok(()))
}
}
/// 将「SendInput + 键盘列表可见性」偏好同步到当前用户的 TSF 语言配置文件。
pub fn apply_windows_openless_keyboard_list_pref(prefs: &UserPreferences) -> Result<(), String> {
let desired = desired_openless_language_profile_enabled(prefs);
#[cfg(target_os = "windows")]
{
let status = get_windows_ime_status();
if status.state != WindowsImeInstallState::Installed {
if desired {
return Ok(());
}
return Err(
"OpenLess TSF IME is not installed; cannot hide it from the keyboard list"
.to_string(),
);
if let Some(result) = keyboard_list_pref_short_circuit(status.state, desired) {
return result;
}
set_openless_language_profile_enabled(desired).map_err(|err| {
let message = err.to_string();
@@ -806,6 +825,62 @@ mod tests {
};
assert!(!desired_openless_language_profile_enabled(&sendinput_hide));
}
// ── Fix: TSF IME 未安装时「键盘列表可见性」偏好不应报错 ──
// 之前 `desired == false`(不显示)+ 未安装错误地返回 Err,经 settings.rs 的
// apply_keyboard_list(&prefs)? 传播导致整个设置保存事务回滚。这是回归护栏。
#[test]
fn uninstalled_hide_request_is_noop_ok() {
// 用户想让 OpenLess 不出现在键盘列表,但 TSF IME 没装 → 天然已满足 → Ok(())。
assert_eq!(
keyboard_list_pref_short_circuit(WindowsImeInstallState::NotInstalled, false),
Some(Ok(()))
);
}
#[test]
fn uninstalled_show_request_is_noop_ok() {
// 用户想显示但没装 → 只能 no-op(没东西可启用)→ Ok(())。
assert_eq!(
keyboard_list_pref_short_circuit(WindowsImeInstallState::NotInstalled, true),
Some(Ok(()))
);
}
#[test]
fn broken_registration_short_circuits_ok_for_both_desired_values() {
// 注册损坏同样视为「列表里没有可信条目」→ 两支都短路成 Ok(()),绝不 Err。
assert_eq!(
keyboard_list_pref_short_circuit(WindowsImeInstallState::RegistrationBroken, false),
Some(Ok(()))
);
assert_eq!(
keyboard_list_pref_short_circuit(WindowsImeInstallState::RegistrationBroken, true),
Some(Ok(()))
);
}
#[test]
fn not_windows_state_short_circuits_ok() {
assert_eq!(
keyboard_list_pref_short_circuit(WindowsImeInstallState::NotWindows, false),
Some(Ok(()))
);
}
#[test]
fn installed_state_proceeds_to_real_profile_mutation() {
// 已安装 → 不短路,交给真正的 EnableLanguageProfile 变更。
assert_eq!(
keyboard_list_pref_short_circuit(WindowsImeInstallState::Installed, false),
None
);
assert_eq!(
keyboard_list_pref_short_circuit(WindowsImeInstallState::Installed, true),
None
);
}
}
#[cfg(all(test, target_os = "windows"))]
+1 -1
View File
@@ -54,7 +54,7 @@
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src": "'self' https://fonts.gstatic.com",
"img-src": "'self' asset: http://asset.localhost blob: data:",
"img-src": "'self' asset: http://asset.localhost blob: data: https://github.com https://avatars.githubusercontent.com",
"connect-src": "'self' ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420",
"object-src": "'none'",
"base-uri": "'none'",
+16 -10
View File
@@ -33,12 +33,18 @@ const Onboarding = lazy(() =>
import('./components/Onboarding').then(m => ({ default: m.Onboarding })),
);
const QaPanel = lazy(() => import('./pages/QaPanel').then(m => ({ default: m.QaPanel })));
const LessComputerPanel = lazy(() =>
import('./pages/LessComputerPanel').then(m => ({ default: m.LessComputerPanel })),
);
const LessComputerGlow = lazy(() =>
import('./pages/LessComputerGlow').then(m => ({ default: m.LessComputerGlow })),
);
// Less Computer 仅 macOS 开放(后端只在 macOS 注册热键/创建窗口)。Tauri 构建时
// TAURI_ENV_PLATFORM 是编译期字面量:非 macOS 平台下面两个三元的 import() 分支
// 被常量折叠 + DCE 整个裁掉,面板 chunk 不进打包产物(门控 = 不打包)。
// 纯浏览器 vite 环境(预览/调样式)没有该变量 → 保持可加载。
const TAURI_BUILD_PLATFORM: string | undefined = import.meta.env.TAURI_ENV_PLATFORM;
const LESS_COMPUTER_BUNDLED = !TAURI_BUILD_PLATFORM || TAURI_BUILD_PLATFORM === 'darwin';
const LessComputerPanel = LESS_COMPUTER_BUNDLED
? lazy(() => import('./pages/LessComputerPanel').then(m => ({ default: m.LessComputerPanel })))
: null;
const LessComputerGlow = LESS_COMPUTER_BUNDLED
? lazy(() => import('./pages/LessComputerGlow').then(m => ({ default: m.LessComputerGlow })))
: null;
interface AppProps {
isCapsule: boolean;
@@ -63,18 +69,18 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force
);
}
if (isLessComputer) {
return (
return LessComputerPanel ? (
<Suspense fallback={null}>
<LessComputerPanel />
</Suspense>
);
) : null;
}
if (isLessComputerGlow) {
return (
return LessComputerGlow ? (
<Suspense fallback={null}>
<LessComputerGlow />
</Suspense>
);
) : null;
}
const os = forcedOs ?? detectOS();
@@ -80,7 +80,11 @@ export function Heatmap({
const columns: { days: { iso: string; date: Date; value: number | null }[]; monthStart: number | null }[] = [];
let maxValue = 0;
let lastMonth = -1;
// 防御上限:正常 365 天调用约 53 列,远低于此值;超大跨度时钳到 ~5 年,
// 避免生成数千 DOM 列拖垮渲染。
const MAX_WEEKS = 260;
while (cursor <= endDate) {
if (columns.length >= MAX_WEEKS) break;
const days: { iso: string; date: Date; value: number | null }[] = [];
let monthStart: number | null = null;
for (let i = 0; i < 7; i += 1) {
-103
View File
@@ -1,103 +0,0 @@
// Marker.tsx — 会话内联标记(shadcn Marker 的纯 React 版):状态更新、系统备注、
// 带底边框的行、带标签的分隔线。与消息流并排使用(LC 面板工具行 / 费用行等)。
//
// 颜色刻意 inherit:深色(LC 面板)与主题化(QA 面板)容器各自给色,
// 组件只负责结构与布局。流式状态配 role="status"(辅助技术播报)+ 调用方的
// 扫光 class(shimmer)。分隔线的两侧线条是装饰元素,不加 role="separator"
//(带文字的分隔线取文字为内容,separator role 反而会吞掉可见标签)。
import type { CSSProperties, ReactNode } from 'react';
type MarkerVariant = 'default' | 'border' | 'separator';
interface MarkerProps {
variant?: MarkerVariant;
/** 流式/进行中状态传 "status",让辅助技术播报更新。 */
role?: string;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export function Marker({ variant = 'default', role, className, style, children }: MarkerProps) {
if (variant === 'separator') {
return (
<div role={role} className={className} style={{ ...separatorStyle, ...style }}>
<span aria-hidden style={separatorLineStyle} />
<span style={{ flexShrink: 0 }}>{children}</span>
<span aria-hidden style={separatorLineStyle} />
</div>
);
}
return (
<div
role={role}
className={className}
style={{
...rowStyle,
...(variant === 'border' ? borderStyle : null),
...style,
}}
>
{children}
</div>
);
}
/** 装饰图标槽:对辅助技术隐藏,含义由相邻的 MarkerContent 承担。 */
export function MarkerIcon({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return (
<span aria-hidden style={{ ...iconStyle, ...style }}>
{children}
</span>
);
}
export function MarkerContent({
children,
className,
style,
}: {
children: ReactNode;
className?: string;
style?: CSSProperties;
}) {
return (
<span className={className} style={style}>
{children}
</span>
);
}
const rowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 0,
};
const borderStyle: CSSProperties = {
paddingBottom: 8,
borderBottom: '0.5px solid rgba(127, 127, 127, 0.22)',
};
const separatorStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
minWidth: 0,
};
const separatorLineStyle: CSSProperties = {
flex: 1,
height: 0.5,
background: 'currentColor',
opacity: 0.18,
};
const iconStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
};
@@ -1,130 +0,0 @@
// MarketplaceModal.tsx — 风格市场弹窗。
// 跟 SettingsModal 同款 backdrop + 居中卡片。内容直接复用 <Marketplace />。
// 入口在 Style 页面「风格包」标题右侧,由 Style.tsx 控制 open/close。
// 顶部 pill 显示当前「登录身份」(dev 模式 = marketplaceDevLogin),未填时引导跳 Settings。
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { Marketplace } from '../pages/Marketplace';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
interface MarketplaceModalProps {
onClose: () => void;
}
export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
const { t } = useTranslation();
const { prefs } = useHotkeySettings();
const login = (prefs?.marketplaceDevLogin ?? '').trim();
const loggedIn = login.length > 0;
// Esc 关闭
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
return (
<div
onClick={onClose}
style={{
position: 'absolute', inset: 0,
background: 'rgba(15,17,22,0.32)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
// Drawer 逻辑(用户拍板):风格市场从右缘滑入的全高抽屉,右上角 ✕ 关闭。
display: 'flex', alignItems: 'stretch', justifyContent: 'flex-end',
zIndex: 50,
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: 'min(1080px, 92%)', height: '100%',
// 底色渐变到实面:主窗口玻璃卡自带 backdrop-filter,嵌套的内层
// backdrop-filter 在 WebKit 里不可靠——顶部留一丝通透,中下部落到
// --ol-surface 实面,不依赖模糊也保证底下内容不透出。
background: 'linear-gradient(180deg, var(--ol-glass-bg-strong), var(--ol-surface) 62%)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
borderRadius: '14px 0 0 14px',
borderLeft: '0.5px solid var(--ol-line)',
boxShadow: '-24px 0 60px -24px rgba(15,17,22,.4), 0 0 0 0.5px rgba(0,0,0,.06)',
overflow: 'hidden',
position: 'relative',
display: 'flex', flexDirection: 'column',
animation: 'ol-drawer-in-right 0.32s var(--ol-motion-spring)',
}}
>
<div
style={{
position: 'absolute', top: 16, left: 24, zIndex: 3,
display: 'inline-flex', alignItems: 'center', gap: 8,
}}
>
<button
type="button"
tabIndex={-1}
title={loggedIn ? t('marketplace.modal.loggedIn') : t('marketplace.modal.notLoggedIn')}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 10px', borderRadius: 999,
border: loggedIn ? '0.5px solid var(--ol-line)' : '0.5px solid rgba(239,68,68,0.32)',
background: loggedIn ? 'rgba(255,255,255,0.85)' : 'rgba(239,68,68,0.08)',
color: loggedIn ? 'var(--ol-ink-2)' : 'var(--ol-red, #ef4444)',
fontSize: 11.5, fontWeight: 500,
cursor: 'default',
boxShadow: '0 1px 2px rgba(15,17,22,0.05)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
}}
>
<Icon name="user" size={11} />
<span>{loggedIn ? `@${login}` : t('marketplace.modal.notLoggedInLabel')}</span>
</button>
</div>
<button
onClick={onClose}
style={{
position: 'absolute', top: 16, right: 16, zIndex: 3,
width: 32, height: 32,
border: '0.5px solid var(--ol-line)',
borderRadius: 8,
background: 'rgba(255,255,255,0.85)',
color: 'var(--ol-ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'default',
boxShadow: '0 1px 2px rgba(15,17,22,0.05)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'var(--ol-surface)';
(e.currentTarget as HTMLButtonElement).style.color = 'var(--ol-ink)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.85)';
(e.currentTarget as HTMLButtonElement).style.color = 'var(--ol-ink-2)';
}}
aria-label={t('common.close')}
title={t('common.close')}
>
<Icon name="close" size={14} />
</button>
{/* paddingTop 64 给 X 按钮留位置 —— PageHeader 的 right 槽(刷新/上传)会下沉到 X 下方 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '64px 32px 32px' }}>
<Marketplace />
</div>
</div>
</div>
);
}
@@ -1,222 +0,0 @@
// MessageScroller.tsx — 聊天记录滚动器(shadcn MessageScroller 行为规范的纯 React 实现)。
//
// 用户拍板的聊天滚动标准(详见 ui.shadcn.com/docs/components/radix/message-scroller):
// 1. 永不违背读者意愿移动视口:只有读者已经在底部(pinned)时才跟随流式内容;
// 向上滚动 = 主动退出跟随,位置保持不动;选择文本也是退出信号。
// 2. 新轮次锚定:`anchorKey` 变化时,把内容里最后一个 [data-scroll-anchor] 行
// 定位到视口顶部附近(上方留 anchorPeek 的上一轮 peek 保上下文),回复在下方
// 流式展开 —— 而不是把视口甩到底部。
// 3. 屏外增长:未跟随时新内容在屏外累积、视口纹丝不动,浮现「跳到最新」按钮;
// 点击回到底部并恢复跟随。内容高度变化用 ResizeObserver 检测(markdown 渲染 /
// 图片加载引起的布局变化同样被覆盖,读者不丢位置)。
// 4. 无障碍:内容容器 role="log" + aria-relevant="additions"。
//
// 它只负责滚动行为,不持有消息/状态/传输 —— 行内容由调用方渲染,需要锚定的行
// 自带 data-scroll-anchor 属性。
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
interface MessageScrollerProps {
/** 新轮次标识:变化时执行「锚定到视口顶部」。undefined 时不锚定。 */
anchorKey?: string | number;
/** 锚定时上一轮内容在锚点上方保留的可见高度(px)。 */
anchorPeek?: number;
/**
* 首次挂载的打开位置(shadcn 规范):'last-anchor' 定位到最后一个锚点行
* (通常是用户最后的提问,回复在其下方)——比落到绝对底部更有上下文;
* 无锚点或内容不溢出时退回 'end'。默认 'end'。
*/
defaultScrollPosition?: 'end' | 'last-anchor';
/** 透传 aria-busy(流式进行中)。 */
busy?: boolean;
/** 内容高度变化回调(LC 浮窗用它驱动窗口自适应高度)。 */
onContentResize?: (height: number) => void;
/** 「跳到最新」按钮的无障碍标签。 */
jumpLabel: string;
style?: CSSProperties;
contentStyle?: CSSProperties;
children: ReactNode;
}
/** 距底部小于该值即视为「在底部」(惯性滚动/亚像素误差的容差)。 */
const PIN_THRESHOLD = 32;
export function MessageScroller({
anchorKey,
anchorPeek = 18,
defaultScrollPosition = 'end',
busy,
onContentResize,
jumpLabel,
style,
contentStyle,
children,
}: MessageScrollerProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 跟随态。true = 读者在底部,内容增长时视口跟随;false = 读者已主动离开,不动。
const pinnedRef = useRef(true);
// 程序滚动进行中:期间的 scroll 事件不是读者意图,不更新 pinned。
const programmaticRef = useRef(false);
const [showJump, setShowJump] = useState(false);
const scrollToBottom = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) return;
programmaticRef.current = true;
viewport.scrollTop = viewport.scrollHeight;
requestAnimationFrame(() => {
programmaticRef.current = false;
});
}, []);
const syncJumpVisibility = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const overflow = viewport.scrollHeight - viewport.clientHeight > 4;
setShowJump(overflow && !pinnedRef.current);
}, []);
const onScroll = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport || programmaticRef.current) return;
const atBottom =
viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - PIN_THRESHOLD;
pinnedRef.current = atBottom;
syncJumpVisibility();
}, [syncJumpVisibility]);
// 选择文本 = 读者正在阅读,无论位置立刻退出跟随(内容跳动会毁掉选区)。
const onSelectStart = useCallback(() => {
pinnedRef.current = false;
syncJumpVisibility();
}, [syncJumpVisibility]);
// 内容高度变化(流式增长 / markdown 渲染 / 图片加载):跟随中滚到底,
// 否则保持读者位置、只更新「跳到最新」的显隐。
useEffect(() => {
const content = contentRef.current;
if (!content) return undefined;
const observer = new ResizeObserver(() => {
onContentResize?.(Math.ceil(content.scrollHeight));
if (pinnedRef.current) scrollToBottom();
syncJumpVisibility();
});
observer.observe(content);
return () => observer.disconnect();
}, [onContentResize, scrollToBottom, syncJumpVisibility]);
// 新轮次锚定:最后一个 [data-scroll-anchor](通常是刚发出的用户消息)定位到
// 视口顶部附近。锚定后退出跟随 —— 回复在锚点下方展开,超出视口则屏外增长。
useEffect(() => {
if (anchorKey == null) return;
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return;
const anchors = content.querySelectorAll<HTMLElement>('[data-scroll-anchor]');
const anchor = anchors[anchors.length - 1];
if (!anchor) return;
programmaticRef.current = true;
viewport.scrollTop = Math.max(0, anchor.offsetTop - anchorPeek);
requestAnimationFrame(() => {
programmaticRef.current = false;
});
pinnedRef.current = false;
syncJumpVisibility();
// anchorPeek/syncJumpVisibility 稳定;仅在新轮次时执行一次锚定。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchorKey]);
const jumpToLatest = useCallback(() => {
pinnedRef.current = true;
scrollToBottom();
setShowJump(false);
}, [scrollToBottom]);
// 打开位置(仅首次挂载执行一次):'last-anchor' 把最后一个锚点行定位到视口
// 顶部附近 —— 重开保存的会话时读者落在「自己最后的提问」而不是绝对底部。
const openedRef = useRef(false);
useEffect(() => {
if (openedRef.current) return;
openedRef.current = true;
if (defaultScrollPosition !== 'last-anchor') return;
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return;
const anchors = content.querySelectorAll<HTMLElement>('[data-scroll-anchor]');
const anchor = anchors[anchors.length - 1];
// 无锚点或内容不溢出 → 维持 'end' 行为(ResizeObserver 首轮已滚底)。
if (!anchor || viewport.scrollHeight - viewport.clientHeight <= 4) return;
programmaticRef.current = true;
viewport.scrollTop = Math.max(0, anchor.offsetTop - anchorPeek);
requestAnimationFrame(() => {
programmaticRef.current = false;
});
pinnedRef.current = false;
syncJumpVisibility();
// 仅首挂执行;依赖刻意留空。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{ position: 'relative', ...style }}>
<div
ref={viewportRef}
onScroll={onScroll}
onSelectCapture={onSelectStart}
className="ol-scroll-fade"
style={{ position: 'absolute', inset: 0, overflow: 'auto' }}
>
<div
ref={contentRef}
role="log"
aria-relevant="additions"
aria-busy={busy || undefined}
style={contentStyle}
>
{children}
</div>
</div>
{showJump && (
<button onClick={jumpToLatest} aria-label={jumpLabel} style={jumpButtonStyle}>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden>
<path
d="M6 1.5v9M2.2 7.2 6 10.7l3.8-3.5"
stroke="currentColor"
strokeWidth="1.6"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
);
}
const jumpButtonStyle: CSSProperties = {
position: 'absolute',
left: '50%',
bottom: 10,
transform: 'translateX(-50%)',
width: 28,
height: 28,
borderRadius: 999,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink-2)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
padding: 0,
boxShadow: '0 6px 18px -8px rgba(0, 0, 0, 0.35)',
};
+29 -10
View File
@@ -51,7 +51,8 @@ const RENDER_SCALE = 0.75;
*/
const WARMING_RESOLVED = 0.2;
const VERTEX_SRC = 'attribute vec2 aPos; void main(){ gl_Position=vec4(aPos,0.0,1.0); }';
// 导出给 chat/orbFeed(共享 orb 渲染源:一个 GL context 镜像到多个小头像)。
export const VERTEX_SRC = 'attribute vec2 aPos; void main(){ gl_Position=vec4(aPos,0.0,1.0); }';
const WAVE_FRAGMENT_SRC = `
precision highp float;
@@ -130,7 +131,7 @@ void main(){
// 2. 聚拢改为 uniform uGather 驱动的「出场」:1=六点全聚在圆心(视觉上就是
// wave 汇聚后的那颗光点),0=散开成环 —— 光条收拢的光点由此平滑「化开」
// 成思考圆点,中间没有任何跳变或弹入冲击。
const ORB_FRAGMENT_SRC = `
export const ORB_FRAGMENT_SRC = `
precision highp float;
uniform vec2 iResolution; uniform float iTime;
uniform float uGather;
@@ -282,7 +283,7 @@ function visualVoice(raw: number): number {
}
export function SiriGL({ mode, level, resolved, warming, warmupMs, speed, merging, className, style }: SiriGLProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
// 60Hz 的 level 更新走 ref 桥接:render 只同步数值,绘制循环在 rAF 里读,
// 不因 props 变化重建 GL 管线。
const levelRef = useRef(0);
@@ -299,14 +300,24 @@ export function SiriGL({ mode, level, resolved, warming, warmupMs, speed, mergin
warmupMsRef.current = warmupMs ?? 150;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
const host = hostRef.current;
if (!host) return undefined;
// 每次 effect 都新建 canvas:卸载时我们主动 loseContext 释放 GPU,而浏览器对
// 同一个 canvas 永远返回同一个(已死的)context —— React 复用 DOM 节点时
// (StrictMode 双挂载 / mode 切换重跑 effect)在旧 canvas 上重建管线必然全军
// 覆没(compile failed: null)。新 canvas = 新 context,彻底绕开。
const canvas = document.createElement('canvas');
canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none;';
host.appendChild(canvas);
const gl = canvas.getContext('webgl', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
});
if (!gl) return undefined;
if (!gl) {
canvas.remove();
return undefined;
}
const compile = (type: number, src: string): WebGLShader | null => {
const shader = gl.createShader(type);
@@ -323,14 +334,21 @@ export function SiriGL({ mode, level, resolved, warming, warmupMs, speed, mergin
const vs = compile(gl.VERTEX_SHADER, VERTEX_SRC);
const fs = compile(gl.FRAGMENT_SHADER, mode === 'wave' ? WAVE_FRAGMENT_SRC : ORB_FRAGMENT_SRC);
if (!vs || !fs) return undefined;
if (!vs || !fs) {
canvas.remove();
return undefined;
}
const program = gl.createProgram();
if (!program) return undefined;
if (!program) {
canvas.remove();
return undefined;
}
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[SiriGL] program link failed:', gl.getProgramInfoLog(program));
canvas.remove();
return undefined;
}
gl.useProgram(program);
@@ -438,12 +456,13 @@ export function SiriGL({ mode, level, resolved, warming, warmupMs, speed, mergin
gl.deleteShader(vs);
gl.deleteShader(fs);
gl.getExtension('WEBGL_lose_context')?.loseContext();
canvas.remove();
};
}, [mode]);
return (
<canvas
ref={canvasRef}
<div
ref={hostRef}
className={className}
style={{ display: 'block', pointerEvents: 'none', ...style }}
aria-hidden
@@ -0,0 +1,148 @@
// avatars.tsx — 思考动画与头像。
//
// · ThinkingOrb:与转译胶囊思考态**同一实现**(SiriGL mode="orb",6 个白色
// metaball 圆点相互交错转动,thinking speed=1.5 与 Capsule.tsx 一致)。
// 白色光点需要深色底才可见 —— 深色小舞台(olchat-orb-stage)承托,与面板的
// 米白背景形成区分(用户拍板)。WebGL 不可用时退回静态白点环。
// · StaticOrbDots:settled 消息头像用的静态白点环(不占 GL context——长对话里
// 每条消息一个实时 orb 会撞浏览器 WebGL context 上限)。
// · UserAvatar:GitHub 已登录(prefs.marketplaceDevLogin)显示 github.com/
// {login}.png 头像,未登录 / 加载失败退回 GitHub 图标。仅划词追问(QA)使用。
import { useEffect, useRef, useState } from 'react';
import { ThinkingDots } from '../ThinkingDots';
import { SiriGL, isWebGLAvailable } from '../SiriGL';
import { subscribeOrbFrames } from './orbFeed';
import { getSettings } from '../../lib/ipc/settings';
import { isTauri } from '../../lib/ipc/shared';
import { cn } from './lib/utils';
import './chat.css';
/** 胶囊同款思考动画(深色舞台承托的 SiriGL orb)。 */
export function ThinkingOrb({ size = 56, className }: { size?: number; className?: string }) {
return (
<span
className={cn('olchat-orb-stage', className)}
style={{ width: size, height: size }}
aria-hidden
>
{isWebGLAvailable() ? (
<SiriGL mode="orb" speed={1.5} />
) : (
<ThinkingDots
size={size * 0.5}
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
)}
</span>
);
}
/**
* 头像形态的胶囊思考动画:**旋转中的** orb,与胶囊一模一样(用户拍板:
* 每条助手消息的头像都要转)。不各挂 GL context —— 从共享渲染源
* (orbFeed,全面板唯一 GL context)逐帧 drawImage 镜像到自己的 2D 小画布。
* WebGL 不可用时退回 ThinkingDots 圆环。
*/
export function OrbAvatar({ size = 32 }: { size?: number }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
const target = canvasRef.current;
if (!target) return undefined;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
target.width = Math.round(size * dpr);
target.height = Math.round(size * dpr);
const ctx = target.getContext('2d');
if (!ctx) {
setUnavailable(true);
return undefined;
}
const unsubscribe = subscribeOrbFrames(source => {
// 中心裁切放大(源按胶囊构图,光环只占画面 ~1/3;头像里裁掉外圈留白
// 让转环撑满,动画本体不变)。
const crop = 0.62;
const sw = source.width * crop;
const sh = source.height * crop;
const sx = (source.width - sw) / 2;
const sy = (source.height - sh) / 2;
ctx.clearRect(0, 0, target.width, target.height);
ctx.drawImage(source, sx, sy, sw, sh, 0, 0, target.width, target.height);
});
if (!unsubscribe) {
setUnavailable(true);
return undefined;
}
return unsubscribe;
}, [size]);
if (unavailable) {
return <ThinkingDots size={size * 0.56} />;
}
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ display: 'block', width: size, height: size, pointerEvents: 'none' }}
/>
);
}
/**
* 当前 GitHub 登录名(Marketplace 上传身份,设置里登录后写入 prefs)。
* refreshKey 变化时重取 —— 面板窗口是常驻 webview(hide/show 复用),
* 用新会话信号驱动刷新即可跟上登录状态变化。
*/
export function useGithubLogin(refreshKey?: string | number): string {
const [login, setLogin] = useState('');
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
getSettings()
.then(prefs => {
if (!cancelled) setLogin((prefs.marketplaceDevLogin ?? '').trim());
})
.catch(() => {
/* 未登录 / 读取失败:保持图标兜底。 */
});
return () => {
cancelled = true;
};
}, [refreshKey]);
return login;
}
/** 用户头像(QA 专用):GitHub 头像图 / 未登录 GitHub 图标。 */
export function UserAvatar({ login }: { login: string }) {
const [broken, setBroken] = useState(false);
useEffect(() => setBroken(false), [login]);
const showImage = login.length > 0 && !broken;
return showImage ? (
<img
className="size-8 rounded-full object-cover"
src={`https://github.com/${encodeURIComponent(login)}.png?size=64`}
alt=""
loading="lazy"
draggable={false}
onError={() => setBroken(true)}
/>
) : (
<span className="flex size-8 items-center justify-center text-foreground/70">
<GithubMark />
</span>
);
}
function GithubMark() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
);
}
@@ -0,0 +1,341 @@
/* chat.css — 统一聊天面板(QA 划词追问 / Less Computer)的 Tailwind v4 入口。
*
* 官方 shadcn base-rhea 组件(ui/ 下 1:1 内嵌)需要 tailwind utilities + shadcn
* 主题变量。为了不影响既有 UI:
* · 不引 preflight(全局 reset 会重置主窗口样式);等效的最小 reset 圈在
* .olchat-shell 作用域内。
* · utilities 只扫描 chat/ 目录与两个面板文件(source(none) + @source)。
* · 主题变量固定浅色(背景米白,用户拍板:与思考动画的深色光点区分),
* 不跟随 app 深色模式。
*/
/* utilities 不套 @layer:app 的 global.css 是无层样式(button/a 等全局 reset),
* 无层规则永远压过任何 @layer 内的规则 —— utilities 也保持无层、且本文件随
* 聊天 chunk 在 global.css 之后加载,同特异性冲突按出现顺序由 utilities 胜出。 */
@layer theme, base;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' source(none);
/* 官方 base-rhea style 的两个 CSS 依赖(与 `shadcn init` 写入完全一致):
* tw-animate-css 动画 utilities + shadcn/tailwind.css(官方 shimmer /
* scroll-fade / scrollbar 等 @utility 与 @custom-variant 定义,按需生成)。 */
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@source './';
@source '../../pages/QaPanel.tsx';
@source '../../pages/LessComputerPanel.tsx';
/* ─── shadcn 主题变量(zinc,浅色固定;纯白 —— 用户拍板与官方 demo 卡片一致)── */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.955 0.002 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.911 0.006 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--radius: 0.625rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* ─── 作用域最小 reset(preflight 的等效子集,只在聊天面板内生效)────── */
@layer base {
.olchat-shell,
.olchat-shell *,
.olchat-shell *::before,
.olchat-shell *::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: var(--color-border, currentColor);
}
.olchat-shell :where(h1, h2, h3, h4, p, ul, ol, li, blockquote, pre, figure) {
margin: 0;
padding: 0;
}
.olchat-shell :where(button, input, textarea, select) {
font: inherit;
color: inherit;
letter-spacing: inherit;
}
.olchat-shell :where(button) {
background-color: transparent;
background-image: none;
text-transform: none;
-webkit-appearance: button;
cursor: pointer;
}
.olchat-shell :where(img, svg, canvas) {
display: block;
vertical-align: middle;
}
.olchat-shell :where(a) {
color: inherit;
text-decoration: inherit;
}
/* 壳:固定浅色(color-scheme 锁 light,深色模式下也保持米白)。 */
.olchat-shell {
color-scheme: light;
font-family: var(--ol-font-sans, system-ui, -apple-system, sans-serif);
/* 消息正文可读可选(body 全局 user-select:none 在面板内恢复)。 */
}
.olchat-shell [data-slot='bubble-content'],
.olchat-shell .olchat-answer,
.olchat-shell [data-slot='marker-content'] {
user-select: text;
-webkit-user-select: text;
cursor: text;
}
}
/* ─── 动画(原 QA/LC 面板动画原样保留:入场 / 流式光标)──────────────── */
@keyframes olchat-shell-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.olchat-shell-in {
animation: olchat-shell-in 0.34s var(--ol-motion-spring, cubic-bezier(0.16, 1, 0.3, 1)) both;
}
/* 退场:关闭时后端先发 chat-panel:closing,前端加本类播 0.22s 退场,240ms 后
* 窗口才真正 hide —— 「从哪里来回哪里去」,与入场对称。定义在 -in 之后,
* 两类同时在场时以后者为准。 */
@keyframes olchat-shell-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
}
.olchat-shell-out {
animation: olchat-shell-out 0.22s cubic-bezier(0.4, 0, 1, 1) both;
}
@keyframes olchat-enter {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.olchat-enter {
animation: olchat-enter 0.3s var(--ol-motion-soft, cubic-bezier(0.16, 1, 0.3, 1)) both;
}
/* ─── 输入组环形跑光(语音状态唯一指示位 —— 用户拍板:录音/思考不进面板上部)──
* 录音 = 红光绕输入框一圈圈跑;转译思考 = 黑光;发送落定后停(去掉 data-ring)。
* 做法:::after 盖在 InputGroup 边缘,conic-gradient 彗尾 + @property 角度动画,
* mask 抠成 2px 环,只亮边不遮内容。 */
@property --olchat-ring-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.olchat-ring {
position: relative;
}
.olchat-ring[data-ring]::after {
content: '';
position: absolute;
inset: -2px;
border-radius: calc(var(--radius-2xl) + 2px);
padding: 2px;
background: conic-gradient(
from var(--olchat-ring-angle),
transparent 0deg 250deg,
var(--olchat-ring-color) 320deg 344deg,
transparent 360deg
);
-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: olchat-ring-spin 1.1s linear infinite;
pointer-events: none;
}
.olchat-ring[data-ring='recording'] {
--olchat-ring-color: oklch(0.637 0.237 25.331); /* red-500 */
}
.olchat-ring[data-ring='thinking'] {
--olchat-ring-color: var(--color-foreground);
}
@keyframes olchat-ring-spin {
to {
--olchat-ring-angle: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.olchat-ring[data-ring]::after {
animation: none;
background: conic-gradient(var(--olchat-ring-color) 0 100%);
opacity: 0.55;
}
}
@keyframes olchat-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.94);
}
}
.olchat-caret {
display: inline-block;
width: 6px;
height: 12px;
background: var(--color-foreground);
animation: olchat-pulse 0.9s ease-in-out infinite;
border-radius: 1px;
}
@media (prefers-reduced-motion: reduce) {
.olchat-shell-in,
.olchat-enter,
.olchat-caret {
animation: none;
}
}
/* ─── 胶囊同款思考动画的承托(SiriGL orb 白色光点需要深色底才可见)──── */
.olchat-orb-stage {
position: relative;
overflow: hidden;
background: #0b0b0f;
border-radius: 16px;
flex-shrink: 0;
}
.olchat-orb-stage > div {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* ─── 助手 markdown 正文(原 qa-answer / lc-answer 合并,token 换 shadcn)── */
.olchat-answer {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--color-foreground);
word-break: break-word;
}
.olchat-answer p {
margin: 0 0 6px;
}
.olchat-answer p:last-child {
margin-bottom: 0;
}
.olchat-answer h1,
.olchat-answer h2,
.olchat-answer h3 {
margin: 10px 0 5px;
font-weight: 600;
line-height: 1.35;
}
.olchat-answer h1 {
font-size: 0.9375rem;
}
.olchat-answer h2 {
font-size: 0.875rem;
}
.olchat-answer h3 {
font-size: 0.8125rem;
}
.olchat-answer ul,
.olchat-answer ol {
margin: 0 0 6px;
padding-left: 18px;
}
.olchat-answer li {
margin: 2px 0;
}
.olchat-answer code {
font-family: var(--ol-font-mono, ui-monospace, monospace);
font-size: 0.75rem;
padding: 1px 5px;
border-radius: 4px;
background: var(--color-muted);
}
.olchat-answer pre {
margin: 0 0 6px;
padding: 8px 10px;
border-radius: 8px;
background: var(--color-muted);
overflow-x: auto;
}
.olchat-answer pre code {
padding: 0;
background: transparent;
}
.olchat-answer a {
color: var(--color-foreground);
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.olchat-answer blockquote {
margin: 0 0 6px;
padding: 4px 0 4px 8px;
border-left: 2px solid var(--color-border);
color: var(--color-muted-foreground);
}
.olchat-answer hr {
border: 0;
border-top: 1px solid var(--color-border);
margin: 8px 0;
}
@@ -0,0 +1,64 @@
// 统一聊天面板组件库出口。
//
// 官方 shadcn base-rhea 组件(ui/ 目录源码按官方 Manual 安装 1:1 内嵌,行为来自
// @shadcn/react;两个面板 = 官方 message-scroller-demo 同款骨架):
// · 骨架 Card* / Empty* / InputGroup* —— 对话框壳、空状态、输入组。
// · 滚动框 MessageScroller* —— 两个面板共用的「扫描框」。
// · 消息 Message*/Bubble* —— 划词追问(QA)的对话形态(带头像)。
// · 标记 Marker* —— Less Computer 的工具调用 / 上下文压缩标签形态(无头像)。
// 本仓库组装层:ThinkingOrb/OrbAvatar(胶囊同款思考动画)、UserAvatar(GitHub)、
// AssistantMarkdown(流式 markdown)、useChatPanelLifecycle(出现/消失动画信号)。
export { Button, buttonVariants } from './ui/button';
export { Input } from './ui/input';
export { Textarea } from './ui/textarea';
export { Spinner } from './ui/spinner';
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from './ui/card';
export {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from './ui/empty';
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from './ui/input-group';
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from './ui/message-scroller';
export {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
} from './ui/message';
export { Bubble, BubbleContent, BubbleGroup } from './ui/bubble';
export { Marker, MarkerContent, MarkerIcon, markerVariants } from './ui/marker';
export { ThinkingOrb, OrbAvatar, UserAvatar, useGithubLogin } from './avatars';
export { AssistantMarkdown } from './markdown';
export { useChatPanelLifecycle } from './lifecycle';
@@ -0,0 +1,7 @@
// shadcn 官方 cn 工具(lib/utils.ts 原样):clsx 组合 + tailwind-merge 去冲突。
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,51 @@
// lifecycle.ts — 聊天面板(qa / less-computer)的出现/消失动画信号。
//
// 浮窗是常驻 webview(hide/show 复用,不重挂载),CSS 入场动画只在首次 mount
// 播一次——之后每次唤起都是「突然出现」。后端在 show/hide 路径发生命周期事件:
// · `chat-panel:shown` → enterEpoch+1(作 key 重放 olchat-shell-in 入场)
// · `chat-panel:closing` → closing=true(加 olchat-shell-out 播退场,240ms 后
// 后端才真正 hide;期间再次 shown 会复位)
// 浏览器预览(非 Tauri)没有这些事件,保持首次 mount 的入场动画即可。
import { useEffect, useState } from 'react';
import { isTauri } from '../../lib/ipc/shared';
export function useChatPanelLifecycle(): { enterEpoch: number; closing: boolean } {
const [enterEpoch, setEnterEpoch] = useState(0);
const [closing, setClosing] = useState(false);
useEffect(() => {
if (!isTauri) return;
let unShown: (() => void) | undefined;
let unClosing: (() => void) | undefined;
let cancelled = false;
(async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
const shownHandle = await listen('chat-panel:shown', () => {
setClosing(false);
setEnterEpoch(epoch => epoch + 1);
});
const closingHandle = await listen('chat-panel:closing', () => {
setClosing(true);
});
if (cancelled) {
shownHandle();
closingHandle();
} else {
unShown = shownHandle;
unClosing = closingHandle;
}
} catch (error) {
console.error('[chat-panel] lifecycle listener setup failed', error);
}
})();
return () => {
cancelled = true;
unShown?.();
unClosing?.();
};
}, []);
return { enterEpoch, closing };
}
@@ -0,0 +1,40 @@
// markdown.tsx — 助手 markdown 正文(原 QaPanel/LessComputerPanel 的
// AssistantText 去重合并):帧率节流 + 渲染失败降级纯文本 + DOMPurify 兜底
// 消毒,流式时末尾带脉冲光标。样式 .olchat-answer(chat.css)。
import { useMemo } from 'react';
import DOMPurify from 'dompurify';
import { useRafThrottle } from '../../lib/useRafThrottle';
import { renderQaMarkdown, renderQaPlainText } from '../../lib/qaMarkdown';
import './chat.css';
interface AssistantMarkdownProps {
markdown: string;
streaming?: boolean;
}
export function AssistantMarkdown({ markdown, streaming = false }: AssistantMarkdownProps) {
// 按帧率节流:流式回复逐 token 全量 parse + DOMPurify 是 O(n²),长回复越来越卡。
const throttled = useRafThrottle(markdown);
const html = useMemo(() => {
let rendered: string;
try {
rendered = renderQaMarkdown(throttled);
} catch (error) {
console.error('[chat] markdown render failed', error);
rendered = renderQaPlainText(String(throttled ?? ''));
}
// 兜底再消毒:qaMarkdown 已转义 raw HTML token,DOMPurify 多一道防线。
return DOMPurify.sanitize(rendered, { ADD_ATTR: ['target', 'rel'] });
}, [throttled]);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 3 }}>
<div
className="olchat-answer"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
{streaming && <span className="olchat-caret" />}
</div>
);
}
@@ -0,0 +1,129 @@
// orbFeed.ts — 共享的胶囊思考 orb 渲染源。
//
// 需求:每条助手消息的头像都要显示**旋转中的**胶囊思考动画(SiriGL orb,
// 6 白色 metaball 圆点交错转动)。若每个头像各挂一个 SiriGL,就是每头像一个
// WebGL context —— 浏览器上限 ~16 个,长对话必炸。
//
// 方案:模块级单例。首个订阅者到来时创建一块**离屏 canvas + 唯一 GL context**
// 跑 orb 渲染循环(shader 与 SiriGL 完全同源,speed=1.5 与胶囊思考一致);
// 每帧渲染完成后**同步**回调所有订阅者,订阅者用 2D drawImage 把源画面镜像到
// 自己的小 canvas(同一任务内拷贝,无需 preserveDrawingBuffer)。最后一个订阅者
// 离开时停表并释放 GL 资源。窗口隐藏时 rAF 被 WebKit/Chromium 挂起,循环自然停。
import { ORB_FRAGMENT_SRC, VERTEX_SRC } from '../SiriGL';
type FrameSubscriber = (source: HTMLCanvasElement) => void;
/** 源分辨率:头像最大 ~40px@2x,96 足够清晰且渲染开销可忽略。 */
const SOURCE_SIZE = 96;
/** 与胶囊思考态一致的转速(Capsule.tsx: transcribing/polishing → speed 1.5)。 */
const SPEED = 1.5;
interface Feed {
canvas: HTMLCanvasElement;
dispose: () => void;
}
const subscribers = new Set<FrameSubscriber>();
let feed: Feed | null = null;
function startFeed(): Feed | null {
const canvas = document.createElement('canvas');
canvas.width = SOURCE_SIZE;
canvas.height = SOURCE_SIZE;
const gl = canvas.getContext('webgl', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
});
if (!gl) return null;
const compile = (type: number, src: string): WebGLShader | null => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('[orbFeed] shader compile failed:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
};
const vs = compile(gl.VERTEX_SHADER, VERTEX_SRC);
const fs = compile(gl.FRAGMENT_SHADER, ORB_FRAGMENT_SRC);
if (!vs || !fs) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[orbFeed] program link failed:', gl.getProgramInfoLog(program));
return null;
}
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const uResolution = gl.getUniformLocation(program, 'iResolution');
const uTime = gl.getUniformLocation(program, 'iTime');
const uGather = gl.getUniformLocation(program, 'uGather');
gl.viewport(0, 0, SOURCE_SIZE, SOURCE_SIZE);
let shaderTime = 0;
let raf = 0;
let last = performance.now();
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
shaderTime += dt * SPEED;
gl.uniform2f(uResolution, SOURCE_SIZE, SOURCE_SIZE);
gl.uniform1f(uTime, shaderTime);
// 头像形态常驻散开转动(不做出场聚拢:镜像目标随时挂载/卸载)。
if (uGather) gl.uniform1f(uGather, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// 同一任务内通知订阅者 drawImage —— WebGL 画面在本任务结束前有效。
for (const cb of subscribers) cb(canvas);
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return {
canvas,
dispose() {
cancelAnimationFrame(raf);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
gl.getExtension('WEBGL_lose_context')?.loseContext();
},
};
}
/**
* 订阅共享 orb:每帧渲染后回调(参数是源 canvas,同步 drawImage 拷贝)。
* 返回退订函数。GL 不可用时返回 null(调用方自行降级)。
*/
export function subscribeOrbFrames(cb: FrameSubscriber): (() => void) | null {
if (!feed) {
feed = startFeed();
if (!feed) return null;
}
subscribers.add(cb);
return () => {
subscribers.delete(cb);
if (subscribers.size === 0 && feed) {
feed.dispose();
feed = null;
}
};
}
@@ -0,0 +1,128 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/30",
className
),
},
props
),
render,
state: {
slot: "bubble-content",
},
})
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
@@ -0,0 +1,62 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-2xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
inert,
...props
}: ButtonPrimitive.Props &
VariantProps<typeof buttonVariants> & { inert?: boolean }) {
// React 18 兼容:@shadcn/react 按 React 19 传布尔 inert,React 18 会把 false
// 渲染成 inert="false"(HTML 中属性存在即生效)。归一成「有/无属性」。
const inertAttr = inert ? ({ inert: "" } as Record<string, unknown>) : undefined
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...inertAttr}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,100 @@
import * as React from "react"
import { cn } from "../lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] bg-card py-(--card-spacing) text-sm text-card-foreground shadow-sm ring-1 ring-foreground/5 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-[min(var(--radius-4xl),24px)] px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("cn-font-heading text-base font-medium", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-t]:pt-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,105 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-3xl border-dashed p-12 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"cn-font-heading text-lg font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
@@ -0,0 +1,155 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../lib/utils"
import { Button } from "./button"
import { Input } from "./input"
import { Textarea } from "./textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-2xl border border-transparent bg-input/50 transition-[color,box-shadow] duration-200 outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-2xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 rounded-2xl text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-xl px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-xl p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "../lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-2xl border border-transparent bg-input/50 px-2.5 py-1 text-base transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,71 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../lib/utils"
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
}
)
function Marker({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(markerVariants({ variant, className })),
},
props
),
render,
state: {
slot: "marker",
variant,
},
})
}
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export { Marker, MarkerIcon, MarkerContent, markerVariants }
@@ -0,0 +1,128 @@
import * as React from "react"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { cn } from "../lib/utils"
import { Button } from "./button"
import { ArrowDownIcon } from "lucide-react"
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>
) {
return <MessageScrollerPrimitive.Provider {...props} />
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className
)}
{...props}
/>
)
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
className
)}
{...props}
/>
)
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("flex h-max min-h-full flex-col gap-8", className)}
{...props}
/>
)
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className
)}
{...props}
/>
)
}
function MessageScrollerButton({
direction = "end",
className,
children,
render,
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "../lib/utils"
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
)
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
}
@@ -0,0 +1,18 @@
import * as React from "react"
import { Loader2Icon } from "lucide-react"
import { cn } from "../lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
data-slot="spinner"
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "../lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full resize-none rounded-2xl border border-transparent bg-input/50 px-2.5 py-2 text-base transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+2 -1
View File
@@ -56,7 +56,7 @@ export const en: typeof zhCN = {
mobileRecordLabel: 'record button',
mobileRecordStart: 'Start recording',
mobileRecordStop: 'Stop and submit',
composerPlaceholder: 'Type a question. Enter to send, Shift+Enter for a new line',
composerPlaceholder: 'Type a question. Enter to send',
composerSend: 'Send',
statusIdle: 'Press {{recordHotkey}} to ask',
statusRecording: 'Recording',
@@ -66,6 +66,7 @@ export const en: typeof zhCN = {
},
lessComputer: {
title: 'Less Computer',
subtitle: 'What should your computer do?',
you: 'You',
working: 'Operating…',
tool: 'Used {{name}}',
+2 -1
View File
@@ -58,7 +58,7 @@ export const ja: typeof zhCN = {
mobileRecordLabel: '録音ボタン',
mobileRecordStart: '録音を開始',
mobileRecordStop: '終了して送信',
composerPlaceholder: '質問を入力。Enter で送信、Shift+Enter で改行',
composerPlaceholder: '質問を入力。Enter で送信',
composerSend: '送信',
statusIdle: '{{recordHotkey}} で質問',
statusRecording: '録音中',
@@ -68,6 +68,7 @@ export const ja: typeof zhCN = {
},
lessComputer: {
title: 'Less Computer',
subtitle: 'コンピュータに何をさせますか?',
you: 'あなた',
working: '操作中…',
tool: '{{name}} を使用',
+2 -1
View File
@@ -58,7 +58,7 @@ export const ko: typeof zhCN = {
mobileRecordLabel: '녹음 버튼',
mobileRecordStart: '녹음 시작',
mobileRecordStop: '종료하고 제출',
composerPlaceholder: '질문을 입력하세요. Enter로 보내고 Shift+Enter로 줄바꿈',
composerPlaceholder: '질문을 입력하세요. Enter로 보내기',
composerSend: '보내기',
statusIdle: '{{recordHotkey}} 로 질문',
statusRecording: '녹음 중',
@@ -68,6 +68,7 @@ export const ko: typeof zhCN = {
},
lessComputer: {
title: 'Less Computer',
subtitle: '컴퓨터로 무엇을 할까요?',
you: '나',
working: '조작 중…',
tool: '{{name}} 사용',
+2 -1
View File
@@ -54,7 +54,7 @@ export const zhCN = {
mobileRecordLabel: '录音按钮',
mobileRecordStart: '开始录音',
mobileRecordStop: '结束并提交',
composerPlaceholder: '输入问题,Enter 发送,Shift+Enter 换行',
composerPlaceholder: '输入问题,Enter 发送',
composerSend: '发送',
statusIdle: '按 {{recordHotkey}} 提问',
statusRecording: '录音中',
@@ -64,6 +64,7 @@ export const zhCN = {
},
lessComputer: {
title: 'Less Computer',
subtitle: '想让电脑做什么?',
you: '你',
working: '正在操控电脑…',
tool: '调用了 {{name}}',
+2 -1
View File
@@ -56,7 +56,7 @@ export const zhTW: typeof zhCN = {
mobileRecordLabel: '錄音按鈕',
mobileRecordStart: '開始錄音',
mobileRecordStop: '結束並提交',
composerPlaceholder: '輸入問題,Enter 發送,Shift+Enter 換行',
composerPlaceholder: '輸入問題,Enter 發送',
composerSend: '發送',
statusIdle: '按 {{recordHotkey}} 提問',
statusRecording: '錄音中',
@@ -66,6 +66,7 @@ export const zhTW: typeof zhCN = {
},
lessComputer: {
title: 'Less Computer',
subtitle: '想讓電腦做什麼?',
you: '你',
working: '正在操控電腦…',
tool: '呼叫了 {{name}}',
@@ -0,0 +1,13 @@
import { invokeOrMock } from "./shared"
/**
* 让当前聊天面板窗口成为 key window(获得键盘焦点)。
*
* QA / Less Computer 浮窗以「不抢前台」方式显示(macOS orderFrontRegardless,
* 从不 makeKey)——好处是唤起时不打断用户正在用的 app,代价是窗口不是
* key window 时按键根本进不了 webview(「点了输入框却打不出字」的根因)。
* 前端在用户点进输入框时调用本命令,此刻抢焦点正是用户想要的。
*/
export function chatPanelFocusKeyboard(): Promise<void> {
return invokeOrMock("chat_panel_focus_keyboard", undefined, () => undefined)
}
+4 -2
View File
@@ -123,7 +123,6 @@ export {
getQaHotkeyLabel,
setQaHotkey,
qaWindowDismiss,
qaWindowPin,
qaToggleRecording,
qaSubmitText,
} from "./qa"
@@ -132,10 +131,13 @@ export {
export {
lessComputerWindowDismiss,
lessComputerApprove,
lessComputerWindowResize,
lessComputerSubmitText,
lessComputerSync,
} from "./less-computer"
// chat-panel(QA / Less Computer 共用)
export { chatPanelFocusKeyboard } from "./chat-panel"
// updater
export type { LatestBetaRelease, AppUpdateMetadata } from "./updater"
export {
@@ -1,4 +1,5 @@
import { invokeOrMock } from "./shared"
import type { LessComputerEvent } from "../types"
/** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */
export function lessComputerWindowDismiss(): Promise<void> {
@@ -17,15 +18,6 @@ export function lessComputerApprove(
)
}
/** 前端按内容测高后回传,后端 clamp + bottom-anchored 重新摆放浮窗。 */
export function lessComputerWindowResize(height: number): Promise<void> {
return invokeOrMock(
"less_computer_window_resize",
{ height },
() => undefined,
)
}
/** 浮窗打字输入:文字指令直接进入 Less Computer 执行链(与语音同护栏/审批/连续会话)。 */
export function lessComputerSubmitText(text: string): Promise<void> {
return invokeOrMock(
@@ -34,3 +26,9 @@ export function lessComputerSubmitText(text: string): Promise<void> {
() => undefined,
)
}
/** 浮窗 mount 时拉取当前会话的事件缓冲(seq 升序),重放 webview 冷加载期间
* 丢掉的事件(尤其首条 user —— 用户说的话)。 */
export function lessComputerSync(): Promise<LessComputerEvent[]> {
return invokeOrMock("less_computer_sync", undefined, () => [])
}
-4
View File
@@ -16,10 +16,6 @@ export function qaWindowDismiss(): Promise<void> {
return invokeOrMock("qa_window_dismiss", undefined, () => undefined)
}
export function qaWindowPin(pinned: boolean): Promise<void> {
return invokeOrMock("qa_window_pin", { pinned }, () => undefined)
}
export function qaToggleRecording(): Promise<void> {
return invokeOrMock("qa_toggle_recording", undefined, () => undefined)
}
+7 -2
View File
@@ -478,7 +478,7 @@ export interface QaStatePayload {
* Less Computer 语音 Agent 浮窗事件(窗口 label = "less-computer",事件名
* `less-computer:event`)。后端按 `kind` 标记,前端据此把交互渲染成聊天结构。
*/
export type LessComputerEvent =
export type LessComputerEvent = (
/** 一轮用户气泡(语音指令转写)。fresh=true 表示新会话(清空历史);否则追加为后续轮次。 */
| { kind: 'user'; text: string; fresh?: boolean }
/** Agent 启动,进入运行态。 */
@@ -496,7 +496,12 @@ export type LessComputerEvent =
/** 用户从胶囊取消正在运行的 Agent。 */
| { kind: 'cancelled' }
/** 运行出错。 */
| { kind: 'error'; message: string };
| { kind: 'error'; message: string }
) & {
/** 单调事件序号(后端 emit 时编)。用于 less_computer_sync 重放与实时流去重;
* 缓冲锁异常时后端可能省略,无 seq 的事件前端无条件应用。 */
seq?: number;
};
/** 内置语言列表 — 前端 Settings UI 用,后端只接收原生名字符串拼 prompt。
* 添加新语言时直接在这里加一项(原生名),无需修改后端。 */
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -17,6 +17,7 @@ import {
resetAccessibilityPermissionAndRestartApp,
} from '../../lib/ipc';
import type { NetworkCheckResult } from '../../lib/ipc';
import { emitSaved } from '../../lib/savedEvent';
import { getPlatformCapabilities } from '../../lib/platform';
import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '@android/lib/androidMicrophonePermission';
import type {
@@ -170,8 +171,13 @@ export function PermissionsSection() {
</Btn>
)}
{accessibility === 'denied' && (
<Btn variant="ghost" size="sm" onClick={() => { resetAccessibilityPermissionAndRestartApp().catch(console.error); }}>
{t('settings.permissions.restart') ?? '重启'}
<Btn variant="ghost" size="sm" onClick={() => {
resetAccessibilityPermissionAndRestartApp().catch((error) => {
console.error('[settings] reset accessibility permission failed', error);
emitSaved('failed', t('common.operationFailed'));
});
}}>
{t('settings.permissions.restart')}
</Btn>
)}
</div>
+4 -1
View File
@@ -2,6 +2,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const appRoot = fileURLToPath(new URL(".", import.meta.url));
@@ -11,7 +12,9 @@ const isMobileDev =
process.env.TAURI_ENV_PLATFORM === "ios";
export default defineConfig(async () => ({
plugins: [react()],
// tailwindcss:只服务统一聊天面板的官方 shadcn 组件(components/chat/chat.css
// 入口,source 显式圈定 chat/ 与两个面板文件);其余全局样式不走 tailwind。
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@android": path.resolve(appRoot, "android/frontend"),