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:
1 parent
2e3c0f5c29
commit
d8789c783d
53 files changed
+7786
-2016
No files matched your search
Generated
+4332
-78
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
// 存句柄供 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(err) => bail!("等待语音识别结果超时或失败: {err}"),
|
||||
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(
|
||||
|
||||
@@ -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),故仅允许该窗口提交,
|
||||
|
||||
@@ -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(), "新建会话历史必须为空");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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,13 +202,13 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SideAwareComboMonitor {
|
||||
fn drop(&mut 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,23 +355,22 @@ 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' {
|
||||
// 分类与计数复用 `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;
|
||||
} else if ch == '\t' {
|
||||
}
|
||||
super::SendInputCharKind::Tab => {
|
||||
if let Err(e) = press_vk(VK_TAB) {
|
||||
return Err(partial_or_original(typed_chars, e));
|
||||
}
|
||||
typed_chars += 1;
|
||||
sent_in_chunk += 1;
|
||||
} else {
|
||||
}
|
||||
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) {
|
||||
@@ -381,9 +380,10 @@ mod windows_impl {
|
||||
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,9 +497,27 @@ mod windows_impl {
|
||||
) -> Result<(), TisError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn classify_sendinput_char(ch: char) -> SendInputCharKind {
|
||||
/// 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(target_os = "windows")]
|
||||
pub(crate) fn classify_sendinput_char(ch: char) -> SendInputCharKind {
|
||||
match ch {
|
||||
'\r' => SendInputCharKind::Skip,
|
||||
'\n' => SendInputCharKind::Newline,
|
||||
@@ -508,13 +526,10 @@ mod windows_impl {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) enum SendInputCharKind {
|
||||
Skip,
|
||||
Newline,
|
||||
Tab,
|
||||
Unicode,
|
||||
}
|
||||
/// 该字符是否计入「已发出的 typed char」。`Skip`(`\r`)不计入,其余都计入。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn sendinput_char_is_typed(kind: SendInputCharKind) -> bool {
|
||||
!matches!(kind, SendInputCharKind::Skip)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
@@ -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 }
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}} を使用',
|
||||
|
||||
@@ -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}} 사용',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, () => [])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user