集成 1.3.14 → beta(主窗 UI 大改 + 一批 fix/feat) (#761)
* fix(insertion): macOS 让「恢复剪贴板」开关真正生效,修复被回退的 #525
#525 的 macOS 剪贴板恢复(commit 4b4d219)被 Android 移植 commit 1ea467a
连同 cfg 门控一起回退:整套恢复机制被重新 #[cfg] 排除出 macOS,macOS insert()
退回「只写剪贴板 + Cmd+V」,于是设置里的「恢复剪贴板」开关在 macOS 上完全无效
——无论开关开还是关,剪贴板都被留成转写文字。
- 把共享的剪贴板恢复机制(ClipboardRestorePlan / schedule_clipboard_restore /
restore_clipboard_after_delay / should_restore_clipboard 等)的 cfg 从
not(any(macos,android,ios)) 放宽为 not(any(android,ios)),重新纳入 macOS
(移动端仍排除;Windows/Linux 不变)。
- macOS insert() 改为:保存原剪贴板 → 写转写文字 → Cmd+V → 仅当「恢复剪贴板」
开关开启且粘贴成功时按 CLIPBOARD_RESTORE_DELAY 延迟恢复;粘贴失败则保留转写
文字供手动粘贴、不恢复。开 = 恢复,关 = 保持现状,开关说了算。
- 删除随之失效的 macos_insert_status_after_paste;恢复相关单测在 macOS 也运行。
Windows/Linux 专用项(insert_with_clipboard_restore / paste_keys /
simulate_paste(shortcut) / 其 insertion_success_status 变体)的 macOS 排除保持不变。
* fix(less-computer): 护栏 sudo/dd/chmod/chown 等设为不可批准,修批准注入畸形规则 (#679)
旧审批流对所有命中 pattern 一律 format!("Bash({p}:*)") 生成 allow 规则:
- 对带空格/参数的子串("sudo " → "Bash(sudo :*)","dd if=" → "Bash(dd if=:*)",
"chmod -r 777 /"、"chown -r")生成的串既不等于 deny "Bash(sudo:*)" 等(故 deny
没被移除、批准静默失效),又把畸形规则注入了 allowed_tools。
改为:新增 guard::deny_rule_for_pattern(pattern),把「可批准」命令(rm -rf / git
push --force / git reset --hard / git clean 等)映射到其在 default_deny_rules 里的
**精确** deny 规则;提权/毁盘/系统级命令(sudo / dd / mkfs / chmod / chown / shutdown
/ reboot / 管道执行 / fork 炸弹)返回 None = 不可批准,即使被批准也保持拦截
(fail-closed),不再注入畸形 allow。审批流用 filter_map 取精确规则,移除的 deny 与
加入的 allow 严格一致。
附单测:每个可批准 pattern 的映射规则确实存在于 default_deny_rules;危险系统命令一律
不可批准。
* fix(ui): unify macOS shell top inset with sidebar/background surface
The 28px top inset that clears the macOS overlay traffic lights sat on the
content wrapper with no background, so it exposed the lighter WindowChrome
glass (--ol-window-bg) as a full-width band above the sidebar and panel —
reading as an extra top row that the Windows build (no inset) doesn't have.
Paint that wrapper with the same --ol-app-shell-bg the sidebar/background
already use, so the inset blends into one continuous surface with the panel
card floating on top. One property; token carries its own light + dark
values; no layout, token, or other UI changes.
* fix(capsule): 胶囊跟随鼠标光标所在屏,修复多屏/多 Space 只在第一块屏显示
多屏 + 多桌面下胶囊只出现在第一块屏、其他屏闪一下就消失。根因:定位
(position_capsule_bottom_center) 用 AX caret 选屏,多数 App 取不到 caret 矩形就
跌回胶囊自己的 current_monitor(第一块屏);而去重缓存 capsule_layout_snapshot 也
用 current_monitor,与定位看的不是同一块屏 → 胶囊一落第一块屏即被判“无变化”跳过
重定位,被锁死。两条路都没跟踪鼠标。
- 新增 mouse_cursor_point():CoreGraphics CGEventGetLocation 取光标,坐标系与 AX
一致、零权限、永远可用
- focused_input_target_monitor → capsule_target_monitor,选屏改“光标优先”(caret
仅兜底),选屏循环抽成 monitor_for_anchor_point
- 定位与去重快照统一走 capsule_target_monitor,彻底消除不一致
- 保留 CAN_JOIN_ALL_SPACES(Spaces 契约强制且本就正确:帧落在光标屏→只那块屏显示)
- 扩展 macos-capsule-spaces 契约测试,grep 守护“光标优先 + 两路共用同一函数”
Windows 路径未动(本就跟随输入 App 屏且快照一致)。
验证:cargo check + lib 纯函数单测 + check:macos-capsule-spaces 全过。
* feat(less-computer): 边缘发光改用 EdgeGlow 式四层模糊堆叠 + iridescent 配色
把 Less Computer 全屏彩虹边缘发光从原来「两条都带模糊的软光带」改成
EdgeGlow(github.com/vector4wang/EdgeGlow,MIT)的实现方式,让彩虹「实线」
更清晰、发光更有层次。
- 4 层模糊堆叠:最内一条 0.5px 锐利彩虹实线(对应 EdgeGlow blur=0 那层,
定义边缘),外面叠 3 层逐渐变宽、变虚的光晕(blur 3/8/15px),得到
霓虹灯管式发光。原生用 CAShapeLayer + 高斯模糊,这里用 CSS「边框遮罩环
+ filter:blur」等价复刻。
- 配色移植 EdgeGlow 默认的 iridescent(Apple Intelligence 高饱和霓虹:
紫→蓝→青→绿→金→橙→红→粉),按 12 段均匀铺满 360°。
- 流光整圈 8s/圈通过 --lcg-angle 旋转(只动渐变角度不动元素形状,GPU 友好),
光晕层各带不同周期呼吸;实线用 normal 保色准,光晕用 screen 叠加增亮。
保留 issue #470 的隐藏即卸载发光层(零 GPU)逻辑、pointer-events:none、
prefers-reduced-motion 降级与 --lcg-radius 圆角变量。纯视觉、仅 macOS、单文件。
* fix(vault): Windows 凭据读取瞬时失败重试,修概率性「火山引擎未配置」
Windows Credential Manager(CredReadW)在登录后/并发下读 manifest+各 chunk 条目
时可能瞬时失败。任一条目读失败会让整组凭据看起来为空 → load_keyring_credentials
返回 Err → load_credentials 回退到空默认值 → 概览页显示「火山引擎未配置」,但下
一次听写重读成功,所以是「概率性」且「实际可正常使用」。凭据 chunk 越多,单次
load 读取次数越多,命中瞬时失败的概率越高。
修法:仅在 Windows 上对非 NoEntry 的读错误做几次短退避重试(4 次 / 60·n ms)。
NoEntry 是确定的「未存储」,立即返回不付重试延迟。macOS/Linux 保持原单次行为
——它们的读错误是 ACL 拒绝,重试无益,且未缓存的错误路径下次调用本就会重读,
加 sleep 只会拖慢 macOS 首次 Keychain 授权流程。纯后端、cfg 隔离,零 PC-macOS 影响。
* fix(asr): 火山引擎连接增加超时与重试,修弱网下无法语音输入
connect_async 此前既无超时也无重试:弱网下 TLS/WebSocket 握手可能挂到 OS 级
TCP 超时(几十秒),期间用户卡在「Starting」;而协调器全局超时只覆盖
await_final_result,不覆盖 open_session,所以握手挂起完全不受约束。单次网络
抖动(连接被重置/瞬时 DNS 失败)也会直接让整次听写失败。
修法:每次握手包 5s 超时,最多 3 次尝试、250·n ms 退避。AuthRejected(凭据被拒)
短路不重试——重试不会变好只会拖慢报错。把请求构造抽成 build_connect_request
(connect_async 消费 request 且 http::Request 非 Clone,需每次重建)。
桌面与安卓共享此路径,改动对两端等效;最坏耗时从「无界(~75s)」降到有界(~15s)
并新增抖动可恢复,对 PC 端只增不减。
* feat(coding-agent): OpenCode CLI 适配器,语音 Agent 可选 OpenCode 后端 (issue #579)
把 OpenCode 当作与 Claude Code 同类的 coding agent CLI 接入,照既有 coding_agent
适配模式(不另造架构),复用同一套 CodingAgentRequest / CodingAgentEvent /
CodingAgentError / 审批护栏链路。
后端:
- coding_agent/opencode.rs(新):detect_opencode(opencode --version)、
build_opencode_args(opencode run --format json --model --dir
[--dangerously-skip-permissions],prompt 作为 argv)、parse_opencode_json_line
(NDJSON:text/tool_use/error;text 块累计,EOF 合成 Completed)、
run_opencode_agent(取消/超时 kill,护栏经 OPENCODE_CONFIG_CONTENT 注入)。
- coding_agent/args.rs:CodingAgentProvider 枚举(claude-code-cli/opencode-cli,
未知回落 Claude)+ from_pref/default_exe。
- coding_agent/guard.rs:build_opencode_guard_config —— 把高风险 bash 前缀翻译成
OpenCode permission.bash deny glob + webfetch deny,审批放行的前缀显式 allow。
- coordinator/dictation_voice_agent: run_less_computer_once 按 coding_agent_provider
分派 Claude / OpenCode 运行器;两路都 fail-closed(护栏生成失败即中止,不裸跑)。
审批拦截探测/重跑放行链路 provider 无关,自动复用。
- commands: coding_agent_detect_opencode 命令(已注册 lib.rs)。
前端:
- CodingAgentSection: OpenCode 选项从「即将支持」改为可用 + 选中后探测安装状态并提示。
- ipc: codingAgentDetectOpencode + OpenCodeDetection。
- i18n: opencodeReady / opencodeMissing(5 语言,替换 providerOpenCodeSoon)。
验证:cargo check + cargo test(492 通过)+ npm run build 均通过。
Refs #579
(#579 还含「语音润色模型配置」:prefs 字段已存在但全链路未实现/未接 UI,
转写直接进 agent 未经语音润色模型整理 —— 故用 Refs 不用 Closes。)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(coding-agent): OpenCode 防参数注入 + 可配置 Agent 可执行文件路径 (#638)
注入风险(@H-Chris233 评审):`opencode run` 的 prompt 作为最后位置参数,但之前缺
end-of-options 分隔。以 `-` / `--` 开头的 prompt(语音转写或被 prompt 注入诱导)会被
OpenCode 当作 flag 解析,可绕过护栏(混入 --dangerously-skip-permissions、--dir 改写
工作目录等)。修复:build_opencode_args 末尾追加 `--`,运行器把 prompt 接在其后;新增单测。
可配置路径(@PR-Agent 指出之前 hardcode "opencode"):新增 prefs.coding_agent_exe,
语音 Agent 路径据此取 exe(留空回退默认 claude / opencode),检测命令亦用配置路径;
设置「高级 → Less Computer」新增「可执行文件路径」输入;补 5 语言 i18n。
* fix(updater): 安装失败可见化 + 手动下载兜底 + 错误写日志 (#706)
下载完成后若 install() 抛错,旧代码把 status 置成 'error',而 UpdateDialog 不渲染 error 态 → 弹框消失、错误只 console.error,用户表现为「下载后无反应、重启仍旧版」(#706)。
- 新增独立状态 installError:仅下载/安装失败用它;'error' 仍表示「检查失败」,行为不变(CheckUpdateButton 按钮内轻提示、后台自动检查都不弹框,零回归)。
- 失败弹框留在原地显示真实错误 + 「手动下载」按钮直达 GitHub Releases 兜底。
- 新增 Rust IPC log_client_error,把前端更新错误写进 openless.log(webview console 不入文件日志),便于用户「导出日志」后定位真因。
- 5 个 i18n 文件补 updateDialog.installError / manualDownload。
注意:这是兜底(失败可见 + 可诊断),不解决 ad-hoc/quarantine 机器自动更新真正成功的问题——那需要 Apple Developer ID 签名 + 公证(本次暂缓)。
* 手动补:dictation/ipc 拆分后的 OpenCode + updater 改动迁移
* fix: AutoUpdate 兼容性修复(补回 CheckUpdateOptions + Android 参数类型)
* fix(audio-cue): 重建/唤醒退化的 AudioContext,修「用久了提示音消失」
长时间使用后「开始录音」提示音与设置页「试听」双双静音,且重启才恢复。
根因在 audioCue.ts 的 AudioContext 单例 sharedCtx 从不检查/恢复运行期状态:
- WKWebView/WebView2 在频繁录音抢占音频会话后,ctx 可能转入 WebKit 非标准的
`interrupted` 态。旧代码只判断 `suspended`,interrupted 被当作可直接排期 →
排到非 running 的 ctx 上,无声且不尝试 resume → 永久静默(macOS 最可能根因)。
- ctx 也可能被系统 `closed`。closed 无法复活(resume 必拒、createOscillator 必抛、
被 catch 静默吞掉),而 getContext 从不重建 → 永久静默。
- 胶囊窗口与设置窗口各持一份 ctx,都会这样退化 → 两处提示音同时失效。
修复:
- 新增纯函数 audioContextActionForState(state):closed→recreate / running→ready /
其余(suspended/interrupted/未知非运行态)→resume,单一真相、可单测。
- getContext 在 ctx 为 closed 时丢弃重建(并清空挂在旧 ctx 上的 activeVoices)。
- playRecordStartCue / primeAudioCue 改用该分类器,把 interrupted 一并按 resume 唤醒。
公共 API 签名不变,仅 AudioCue.tsx / RecordingInputSection.tsx 复用,行为向后兼容。
tsc --noEmit 通过;audioCue.test.ts 新增 5 条状态分类断言,全部通过。
* fix(macos): onboarding Accessibility authorization page stuck (#703 follow-up)
Root cause: permissions::request_accessibility() was called in setup(),
which runs BEFORE the AppKit event loop is live. On some macOS versions,
AXIsProcessTrustedWithOptions depends on the run loop for XPC to tccd.
When the event loop isn't ready, TCC may not show the dialog but still
consumes the one-time prompt. Later calls from the onboarding page's
'grant accessibility' button silently return Denied, trapping users on
the onboarding page. Recovery requires System Settings grant + app
restart, but users had no clear restart action — only a text hint.
Changes:
- lib.rs: Remove setup() TCC prompt. The prompt is now exclusively
triggered by user action on the onboarding page, aligning with
Apple HIG (explain why → then prompt).
- Onboarding.tsx:
* onGrantAccessibility now checks the TCC result: skip
openSystemSettings when already granted (previously always opened)
* New tccPromptShown state distinguishes 'never attempted' vs
'already denied' for correct button labels
* Denied state now shows a prominent 'Restart OpenLess' button
calling restartApp()
- PermissionsSection.tsx: Same fix for settings page accessibility
row — check TCC result, guide to System Settings when denied,
show 'Restart App' button
- i18n: Add onboarding.actionRestart + settings.permissions.restart
for zh-CN / zh-TW / en / ja / ko
Closes: macos-onboarding-accessibility-stuck
* chore(release): publish 1.3.13
* fix(audio-cue): recover when resume() is rejected or wakes non-running
Long sessions park the shared AudioContext in 'suspended' / WebKit
'interrupted' after the system audio session is repeatedly preempted by
ASR and recording. The earlier fix only recreated a 'closed' context: a
rejected resume() was swallowed and a resume() that resolved without
reaching 'running' scheduled voices on a frozen currentTime, both of
which leave the start cue permanently silent until the app restarts.
Treat both "won't wake" states as recoverable — discard the dead context
and retry once on a fresh one, bounded to avoid infinite recreation. The
decision is extracted into the pure, unit-tested cueActionAfterResume.
* chore(release): integrate 1.3.14
* fix(ipc): 去除 coding-agent.ts/index.ts 中 OpenCodeDetection 和 codingAgentDetectOpencode 的重复声明
integrate/cloud-sync-20260621 合并后 IPC barrel 出现重复 export(beta 已含 PR #638 的
OpenCode 适配器,integrate 分支又加了一份)。合并去重,npm build 通过。
* style(less-computer): 气泡/审批/发送按钮改用 accent 主题 token
把 LessComputerPanel 里三处硬编码渐变(#3d6bff→#7a55f2)替换为
--ol-accent-solid-bg / --ol-accent-solid-ink,跟随浅/深主题。
* feat(capsule/asr/ui): 胶囊即时显示与预测展开、Apple 语音修复、弹窗全局遮罩
胶囊入场(延迟出现 / 无入场动画 / 亮起→卡→展开):
- 按下热键即「乐观显示」胶囊并播入场动画,不等麦克风就绪;level_handler 首帧 PCM
到达后从预备态翻正式态、光条点亮(新增 warming 标志贯穿 emit_capsule payload)
- 修入场动画被吞:入场帧把发给前端的事件挪到 window.show 之后再 emit
- 预测式展开:光条按历史平均加载时长(EMA + localStorage 跨会话学习)从按下就平滑
展开,就绪早则加速补满、未就绪则慢爬至 90%,消除「亮起→卡→突然展开」
Apple 语音(说中文识别成英文 / 偶发不可用):
- initWithLocale 跟随用户工作语言主语言,修 init 无参落系统首选语言(英文)导致
中文被英文引擎误识别
- on-device 优先(requiresOnDeviceRecognition):离线可用 + 隐私 + 不受网络波动/限流
- isAvailable 就绪轮询等待,修刚 init 时瞬时不可用就报错的竞态
弹窗遮罩:Modal 用 createPortal 到 body,修被 WindowChrome/FloatingShell 常驻
will-change: transform 祖先困住 fixed 遮罩(登录弹窗浮在亮着的设置页上、无全局压暗)
服务页 Apple 语音文案优化(zh-CN/zh-TW)
---------
Co-authored-by: 吕柏青 <lubaiqing9@gmail.com>
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
67 files changed
+4587
-2043
No files matched your search
Generated
+355
-235
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"private": true,
|
||||
"version": "1.3.13-Beta.1",
|
||||
"version": "1.3.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -44,6 +44,6 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
"vite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
Generated
+4
-1
@@ -3528,6 +3528,9 @@ name = "objc-sys"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
@@ -3883,7 +3886,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openless"
|
||||
version = "1.3.13-Beta.1"
|
||||
version = "1.3.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openless"
|
||||
version = "1.3.13-Beta.1"
|
||||
version = "1.3.14"
|
||||
description = "OpenLess — local voice input that types where your cursor is"
|
||||
authors = ["OpenLess"]
|
||||
edition = "2021"
|
||||
@@ -108,7 +108,7 @@ core-graphics = "0.24"
|
||||
# (替代 10s 轮询,空闲零唤醒)。符号本就在依赖树(cpal → coreaudio-sys 0.2),提升为
|
||||
# 直接依赖零额外编译成本。
|
||||
coreaudio-sys = "0.2"
|
||||
objc2 = "0.5"
|
||||
objc2 = { version = "0.5", features = ["exception"] }
|
||||
objc2-foundation = "0.2"
|
||||
objc2-app-kit = "0.2"
|
||||
# qwen-asr 用 malloc 分配返回字符串,必须用 C `free()` 释放。
|
||||
|
||||
@@ -39,16 +39,25 @@ const SF_AUTH_AUTHORIZED: i64 = 3;
|
||||
/// 这里只防 block 永不回调导致线程永久阻塞。
|
||||
const RECOGNITION_WAIT: Duration = Duration::from_secs(60);
|
||||
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);
|
||||
|
||||
pub struct AppleSpeechAsr {
|
||||
/// 16-bit LE PCM 字节缓冲(recorder 推什么我们存什么)。与 LocalQwenAsr 同形。
|
||||
buffer: Mutex<Vec<u8>>,
|
||||
/// 识别 locale(Apple 标识符,如 "zh-CN")。None = 用系统默认。由用户工作语言映射
|
||||
/// 而来 —— SFSpeechRecognizer 一个实例只认一种语言,不显式指定就落到系统首选语言
|
||||
/// (常是英文),中文语音会被英文引擎识别成英文且理解错误(用户报告的根因)。
|
||||
locale: Option<String>,
|
||||
}
|
||||
|
||||
impl AppleSpeechAsr {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(locale: Option<String>) -> Self {
|
||||
Self {
|
||||
buffer: Mutex::new(Vec::new()),
|
||||
locale,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +82,13 @@ impl AppleSpeechAsr {
|
||||
});
|
||||
}
|
||||
let duration_ms = (pcm.len() as u64 / 2) * 1000 / 16_000;
|
||||
let locale = self.locale.clone();
|
||||
|
||||
// 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)
|
||||
transcribe_pcm_blocking(&pcm, duration_ms, locale.as_deref())
|
||||
})
|
||||
.await
|
||||
.context("apple-speech transcribe spawn_blocking join 失败")?;
|
||||
@@ -96,7 +106,7 @@ impl AppleSpeechAsr {
|
||||
|
||||
impl Default for AppleSpeechAsr {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
Self::new(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +118,11 @@ impl crate::recorder::AudioConsumer for AppleSpeechAsr {
|
||||
|
||||
/// 把 PCM 写成临时 wav,确保授权,跑批处理识别,删临时文件,返回结果。
|
||||
/// 在 spawn_blocking 线程内同步执行。
|
||||
fn transcribe_pcm_blocking(pcm: &[u8], duration_ms: u64) -> Result<RawTranscript> {
|
||||
fn transcribe_pcm_blocking(
|
||||
pcm: &[u8],
|
||||
duration_ms: u64,
|
||||
locale: Option<&str>,
|
||||
) -> Result<RawTranscript> {
|
||||
ensure_authorized()?;
|
||||
|
||||
let samples: Vec<i16> = pcm
|
||||
@@ -129,7 +143,7 @@ fn transcribe_pcm_blocking(pcm: &[u8], duration_ms: u64) -> Result<RawTranscript
|
||||
let path_str = path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("临时 wav 路径含非 UTF-8 字符: {}", path.display()))?;
|
||||
let text = recognize_file(path_str)?;
|
||||
let text = recognize_file(path_str, locale)?;
|
||||
|
||||
Ok(RawTranscript { text, duration_ms })
|
||||
}
|
||||
@@ -183,19 +197,22 @@ fn ensure_authorized() -> Result<()> {
|
||||
|
||||
/// 用 `SFSpeechURLRecognitionRequest` 对给定 wav 文件做一次批处理识别,
|
||||
/// 把 `recognitionTaskWithRequest:resultHandler:` 的异步回调同步化。
|
||||
fn recognize_file(wav_path: &str) -> Result<String> {
|
||||
let recognizer = create_recognizer()?;
|
||||
fn recognize_file(wav_path: &str, locale: Option<&str>) -> Result<String> {
|
||||
let recognizer = create_recognizer(locale)?;
|
||||
|
||||
// recognizer.isAvailable —— 识别引擎当前是否可用(首次可能在下载语言资源)。
|
||||
// SAFETY: `recognizer` 是有效的 `SFSpeechRecognizer` 实例;`isAvailable` 无参,返回 BOOL。
|
||||
let available: Bool = unsafe { msg_send![recognizer, isAvailable] };
|
||||
if !available.as_bool() {
|
||||
bail!("当前语言的语音识别暂不可用(系统可能正在准备识别资源,请稍后重试)");
|
||||
}
|
||||
// 识别引擎就绪等待(isAvailable 竞态):SFSpeechRecognizer 刚 init 时引擎往往还没
|
||||
// 就绪(异步加载语言资源),isAvailable 瞬时为 false、稍等即 true。之前一见 false
|
||||
// 就 bail —— 这正是「有时用不了」的主因。改为轮询等待最多几秒再判定。
|
||||
wait_until_available(recognizer)?;
|
||||
|
||||
let url = file_url(wav_path)?;
|
||||
let request = create_url_request(url)?;
|
||||
|
||||
// on-device 优先:设备支持当前语言的设备端识别就强制 on-device —— 音频不出本机
|
||||
// (隐私)、离线可用、不受网络波动/限流影响(消除「有时连不上服务器」)。不支持的
|
||||
// 语言回退系统默认(可能走网络)以保底能用。
|
||||
configure_on_device(recognizer, request);
|
||||
|
||||
let (tx, rx) = mpsc::channel::<RecognitionOutcome>();
|
||||
// resultHandler: void(^)(SFSpeechRecognitionResult *result, NSError *error)
|
||||
let block = RcBlock::new(move |result: *mut AnyObject, error: *mut AnyObject| {
|
||||
@@ -226,6 +243,44 @@ fn recognize_file(wav_path: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询等待识别引擎就绪。init 后 isAvailable 可能瞬时 false(异步加载资源),稍等
|
||||
/// 即 true;等满 AVAILABILITY_WAIT 仍不可用才报错并引导。
|
||||
fn wait_until_available(recognizer: *mut AnyObject) -> Result<()> {
|
||||
let deadline = std::time::Instant::now() + AVAILABILITY_WAIT;
|
||||
loop {
|
||||
// SAFETY: `recognizer` 有效;`isAvailable` 无参返回 BOOL。
|
||||
let available: Bool = unsafe { msg_send![recognizer, isAvailable] };
|
||||
if available.as_bool() {
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
bail!(
|
||||
"当前语言的语音识别暂不可用:系统可能仍在准备识别资源,或需在 系统设置 → 键盘 → 听写 中下载对应语言。可稍后重试,或改用其它 ASR。"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(AVAILABILITY_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// 支持设备端识别的语言就把请求设成强制 on-device(音频不出本机、离线可用);不支持
|
||||
/// 的语言不设,回退系统默认(可能走网络)以保底能用。
|
||||
fn configure_on_device(recognizer: *mut AnyObject, request: *mut AnyObject) {
|
||||
// SFSpeechRecognizer.supportsOnDeviceRecognition(macOS 10.15+,BOOL 属性)。
|
||||
// SAFETY: `recognizer` 有效;无参返回 BOOL。
|
||||
let supports: Bool = unsafe { msg_send![recognizer, supportsOnDeviceRecognition] };
|
||||
if supports.as_bool() {
|
||||
// SFSpeechRecognitionRequest.requiresOnDeviceRecognition = YES。
|
||||
// SAFETY: `request` 是 SFSpeechURLRecognitionRequest(父类
|
||||
// SFSpeechRecognitionRequest 提供该 setter);参数 BOOL。
|
||||
let _: () = unsafe { msg_send![request, setRequiresOnDeviceRecognition: Bool::new(true)] };
|
||||
log::info!("[apple-speech] on-device recognition enabled");
|
||||
} else {
|
||||
log::info!(
|
||||
"[apple-speech] on-device unsupported for current locale; using default (may use network)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 识别回调的归一化结果。
|
||||
enum RecognitionOutcome {
|
||||
/// 还没拿到 final(partial),不发送。
|
||||
@@ -271,21 +326,74 @@ fn speech_recognizer_class() -> Result<&'static AnyClass> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `[[SFSpeechRecognizer alloc] init]` —— 用系统当前 locale。
|
||||
fn create_recognizer() -> Result<*mut AnyObject> {
|
||||
/// 创建 recognizer。有指定 locale 就 `initWithLocale:`(关键 —— 否则落到系统首选语言,
|
||||
/// 中文语音会被英文引擎误识别);无 locale 或 NSLocale 构造失败时回退 `init`(系统默认)。
|
||||
fn create_recognizer(locale: Option<&str>) -> Result<*mut AnyObject> {
|
||||
let cls = speech_recognizer_class()?;
|
||||
// SAFETY: `cls` 是 `SFSpeechRecognizer` 类;`alloc` 返回未初始化实例,
|
||||
// `init` 对其初始化,返回的实例由本函数所有权移交调用方(随后被 ARC 管理)。
|
||||
let recognizer: *mut AnyObject = unsafe {
|
||||
let alloc: *mut AnyObject = msg_send![cls, alloc];
|
||||
msg_send![alloc, init]
|
||||
let recognizer: *mut AnyObject = match locale.and_then(ns_locale) {
|
||||
Some(ns_loc) => {
|
||||
log::info!("[apple-speech] recognizer locale = {}", locale.unwrap_or(""));
|
||||
// SAFETY: `cls` 是 SFSpeechRecognizer 类;`alloc` 得未初始化实例,
|
||||
// `initWithLocale:` 用有效 NSLocale 初始化,返回实例移交调用方(ARC 管理)。
|
||||
unsafe {
|
||||
let alloc: *mut AnyObject = msg_send![cls, alloc];
|
||||
msg_send![alloc, initWithLocale: ns_loc]
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// SAFETY: 同上;`init` 用系统默认 locale。
|
||||
unsafe {
|
||||
let alloc: *mut AnyObject = msg_send![cls, alloc];
|
||||
msg_send![alloc, init]
|
||||
}
|
||||
}
|
||||
};
|
||||
if recognizer.is_null() {
|
||||
bail!("无法创建 SFSpeechRecognizer(当前系统语言可能不支持语音识别)");
|
||||
bail!("无法创建 SFSpeechRecognizer(当前语言可能不支持语音识别)");
|
||||
}
|
||||
Ok(recognizer)
|
||||
}
|
||||
|
||||
/// `[NSLocale localeWithLocaleIdentifier:<id>]`。构造失败返回 None(调用方回退系统默认)。
|
||||
fn ns_locale(identifier: &str) -> Option<*mut AnyObject> {
|
||||
let ns_id = ns_string_from_str(identifier).ok()?;
|
||||
let cls = AnyClass::get("NSLocale")?;
|
||||
// SAFETY: `cls` 是 NSLocale;`localeWithLocaleIdentifier:` 接收 NSString(`ns_id` 有效),
|
||||
// 返回 autoreleased NSLocale(在 spawn_blocking 线程的 autorelease 池存活)。
|
||||
let loc: *mut AnyObject = unsafe { msg_send![cls, localeWithLocaleIdentifier: ns_id] };
|
||||
if loc.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(loc)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户工作语言(原生名,见前端 `SUPPORTED_LANGUAGES`)→ SFSpeechRecognizer 的 locale
|
||||
/// 标识符。取 `working_languages` 主语言映射;未收录的语言返回 None(回退系统默认 locale)。
|
||||
/// SFSpeechRecognizer 一个实例只认一种语言,中英混说时以主语言为准 —— 这是 Apple 的固有
|
||||
/// 限制,云端 ASR 才能自由多语言混识。
|
||||
pub fn native_name_to_apple_locale(native_name: &str) -> Option<String> {
|
||||
let locale = match native_name.trim() {
|
||||
"简体中文" => "zh-CN",
|
||||
"繁体中文" | "繁體中文" => "zh-TW",
|
||||
"English" => "en-US",
|
||||
"日本語" => "ja-JP",
|
||||
"한국어" => "ko-KR",
|
||||
"Français" => "fr-FR",
|
||||
"Deutsch" => "de-DE",
|
||||
"Español" => "es-ES",
|
||||
"Italiano" => "it-IT",
|
||||
"Português" => "pt-BR",
|
||||
"Русский" => "ru-RU",
|
||||
"العربية" => "ar-SA",
|
||||
"Tiếng Việt" => "vi-VN",
|
||||
"ไทย" => "th-TH",
|
||||
"हिन्दी" => "hi-IN",
|
||||
_ => return None,
|
||||
};
|
||||
Some(locale.to_string())
|
||||
}
|
||||
|
||||
/// `[NSURL fileURLWithPath:<path>]`。
|
||||
fn file_url(path: &str) -> Result<*mut AnyObject> {
|
||||
let ns_path = ns_string_from_str(path)?;
|
||||
@@ -387,7 +495,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn buffer_duration_tracks_consumed_pcm() {
|
||||
let asr = AppleSpeechAsr::new();
|
||||
let asr = AppleSpeechAsr::new(None);
|
||||
assert_eq!(asr.buffer_duration_ms(), 0);
|
||||
// 16k * 2 bytes/sample * 1s = 32000 bytes。
|
||||
asr.consume_pcm_chunk(&vec![0u8; 32_000]);
|
||||
@@ -398,7 +506,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cancel_clears_buffer() {
|
||||
let asr = AppleSpeechAsr::new();
|
||||
let asr = AppleSpeechAsr::new(None);
|
||||
asr.consume_pcm_chunk(&vec![0u8; 32_000]);
|
||||
asr.cancel();
|
||||
assert_eq!(asr.buffer_duration_ms(), 0);
|
||||
@@ -406,7 +514,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn transcribe_empty_buffer_returns_empty() {
|
||||
let asr = AppleSpeechAsr::new();
|
||||
let asr = AppleSpeechAsr::new(None);
|
||||
let transcript = asr.transcribe().await.unwrap();
|
||||
assert_eq!(transcript.text, "");
|
||||
assert_eq!(transcript.duration_ms, 0);
|
||||
|
||||
@@ -38,7 +38,7 @@ mod qwen_ffi;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unused_imports)]
|
||||
pub use apple_speech_provider::AppleSpeechAsr;
|
||||
pub use apple_speech_provider::{native_name_to_apple_locale, AppleSpeechAsr};
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use local_provider::LocalQwenAsr;
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -32,6 +32,15 @@ const BYTES_PER_MS: f64 = 32.0;
|
||||
const HOTWORD_CAP: usize = 80;
|
||||
const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
|
||||
/// 弱网下 TLS/WebSocket 握手可能一直挂到 OS 级 TCP 超时(几十秒),期间用户卡在
|
||||
/// 「Starting」无法语音输入。协调器的全局超时只覆盖 `await_final_result`,**不**覆盖
|
||||
/// `open_session`,所以这里必须自己给握手设上限:超时即快速失败并重试,而不是冻结。
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// 单次网络抖动(连接被重置 / 瞬时 DNS 失败)以前会直接让整次听写失败。重试几次让
|
||||
/// 抖动可恢复。`AuthRejected`(凭据被拒)不在重试之列——重试也不会变好,只会拖慢报错。
|
||||
const CONNECT_MAX_ATTEMPTS: usize = 3;
|
||||
const CONNECT_RETRY_BACKOFF: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VolcengineCredentials {
|
||||
pub app_id: String,
|
||||
@@ -131,34 +140,7 @@ impl VolcengineStreamingASR {
|
||||
}
|
||||
|
||||
let connect_id = Uuid::new_v4().to_string();
|
||||
let mut request = ENDPOINT
|
||||
.into_client_request()
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?;
|
||||
let headers = request.headers_mut();
|
||||
headers.insert(
|
||||
"X-Api-App-Key",
|
||||
HeaderValue::from_str(&self.credentials.app_id)
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-Api-Access-Key",
|
||||
HeaderValue::from_str(&self.credentials.access_token)
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-Api-Resource-Id",
|
||||
HeaderValue::from_str(&self.credentials.resource_id)
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-Api-Connect-Id",
|
||||
HeaderValue::from_str(&connect_id)
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
|
||||
let (ws, _resp) = connect_async(request)
|
||||
.await
|
||||
.map_err(classify_connect_error)?;
|
||||
let ws = self.connect_with_retry(&connect_id).await?;
|
||||
let (write, read) = ws.split();
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
@@ -260,6 +242,79 @@ impl VolcengineStreamingASR {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the WebSocket handshake request (endpoint + auth headers). Rebuilt
|
||||
/// per connect attempt because `connect_async` consumes the request and
|
||||
/// `http::Request` is not `Clone`.
|
||||
fn build_connect_request(
|
||||
&self,
|
||||
connect_id: &str,
|
||||
) -> Result<tokio_tungstenite::tungstenite::handshake::client::Request, VolcengineASRError>
|
||||
{
|
||||
let mut request = ENDPOINT
|
||||
.into_client_request()
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?;
|
||||
let headers = request.headers_mut();
|
||||
headers.insert(
|
||||
"X-Api-App-Key",
|
||||
HeaderValue::from_str(&self.credentials.app_id)
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-Api-Access-Key",
|
||||
HeaderValue::from_str(&self.credentials.access_token)
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-Api-Resource-Id",
|
||||
HeaderValue::from_str(&self.credentials.resource_id)
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-Api-Connect-Id",
|
||||
HeaderValue::from_str(connect_id)
|
||||
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
async fn connect_with_retry(&self, connect_id: &str) -> Result<WsStream, VolcengineASRError> {
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let request = self.build_connect_request(connect_id)?;
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)).await {
|
||||
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
|
||||
{
|
||||
return Err(classified);
|
||||
}
|
||||
log::warn!(
|
||||
"[asr] 连接尝试 {attempt}/{CONNECT_MAX_ATTEMPTS} 失败: {classified};重试中"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
if attempt >= CONNECT_MAX_ATTEMPTS {
|
||||
return Err(VolcengineASRError::ConnectionFailed(format!(
|
||||
"连接超时({} ms)",
|
||||
CONNECT_TIMEOUT.as_millis()
|
||||
)));
|
||||
}
|
||||
log::warn!(
|
||||
"[asr] 连接尝试 {attempt}/{CONNECT_MAX_ATTEMPTS} 超时({} ms);重试中",
|
||||
CONNECT_TIMEOUT.as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(CONNECT_RETRY_BACKOFF * attempt as u32).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_last_frame(&self) -> Result<(), VolcengineASRError> {
|
||||
// 等所有 fire-and-forget 发送完成。否则末帧(NegativeSequence)可能比尾部
|
||||
// chunk 先到服务端,被识别为「流已结束」之后再到的 chunk 全部丢弃 = 尾句吞掉。
|
||||
|
||||
@@ -15,6 +15,8 @@ pub enum CodingAgentEvent {
|
||||
Delta { session_id: String, text: String },
|
||||
/// agent 触发了某个工具(如 Bash / Edit)。
|
||||
ToolUse { session_id: String, name: String },
|
||||
/// 会话上下文被压缩(`system/compact_boundary`)。前端在消息流对应位置内嵌提示。
|
||||
Compaction { session_id: String },
|
||||
/// 运行完成的最终结果。
|
||||
Completed {
|
||||
session_id: String,
|
||||
@@ -65,6 +67,16 @@ pub fn parse_stream_json_line(session_id: &str, line: &str) -> Option<CodingAgen
|
||||
}
|
||||
None
|
||||
}
|
||||
"system" => {
|
||||
// 上下文压缩:headless claude 压缩会话历史时发一行 system/compact_boundary。
|
||||
// 其余 system 行(init 等)继续忽略。
|
||||
if v.get("subtype")?.as_str()? != "compact_boundary" {
|
||||
return None;
|
||||
}
|
||||
Some(CodingAgentEvent::Compaction {
|
||||
session_id: session_id.to_string(),
|
||||
})
|
||||
}
|
||||
"result" => {
|
||||
let is_error = v.get("is_error").and_then(|b| b.as_bool()).unwrap_or(false);
|
||||
let text = v
|
||||
@@ -154,6 +166,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_compact_boundary() {
|
||||
let line = r#"{"type":"system","subtype":"compact_boundary","compact_metadata":{"trigger":"auto","pre_tokens":50000}}"#;
|
||||
assert_eq!(
|
||||
parse_stream_json_line("s1", line),
|
||||
Some(CodingAgentEvent::Compaction {
|
||||
session_id: "s1".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_system_init_and_garbage() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -15,6 +15,18 @@ pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> {
|
||||
coord.history().clear().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 每日活动计数(日期升序),概览页年度热力图的数据源。与历史内容 / 保留策略解耦:
|
||||
/// 清空历史不影响它,全年格子照亮。
|
||||
#[tauri::command]
|
||||
pub fn get_activity_stats(coord: CoordinatorState<'_>) -> Vec<ActivityDay> {
|
||||
coord
|
||||
.activity()
|
||||
.snapshot()
|
||||
.into_iter()
|
||||
.map(|(date, count)| ActivityDay { date, count })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 读取某次会话的原始麦克风 wav 字节流。文件存在的条件:debug 用户的任意会话,或任意
|
||||
/// 「转录失败 / empty」会话(失败保留)——成功的非 debug 会话录音会在插入后删掉。
|
||||
/// 文件名规约:`<data_dir>/recordings/<session_id>.wav`,与 DictationSession.id 同名。
|
||||
|
||||
@@ -56,7 +56,8 @@ pub(crate) use crate::recorder::{AudioConsumer, Recorder};
|
||||
#[cfg(not(mobile))]
|
||||
pub(crate) use crate::types::WindowsImeStatus;
|
||||
pub(crate) use crate::types::{
|
||||
builtin_style_pack_id, default_active_style_pack_id, AndroidAccessibilityStatus,
|
||||
builtin_style_pack_id, default_active_style_pack_id, ActivityDay,
|
||||
AndroidAccessibilityStatus,
|
||||
AndroidOverlayStatus, ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus,
|
||||
DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference,
|
||||
PolishMode, ShortcutBinding, StylePack, StylePackKind, StylePackRuntimeDiagnostics,
|
||||
|
||||
@@ -77,6 +77,12 @@ pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) {
|
||||
coord.less_computer_window_resize(height);
|
||||
}
|
||||
|
||||
/// 浮窗打字输入:文字指令直接进入 Less Computer 执行链(跳过录音与 ASR)。
|
||||
#[tauri::command]
|
||||
pub fn less_computer_submit_text(coord: CoordinatorState<'_>, text: String) {
|
||||
coord.less_computer_submit_text(text);
|
||||
}
|
||||
|
||||
/// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。
|
||||
///
|
||||
/// 安全:审批 UI 渲染在 less-computer 窗口(LessComputerPanel),故仅允许该窗口提交,
|
||||
|
||||
@@ -39,8 +39,8 @@ use crate::correction::apply_correction_rules;
|
||||
use crate::hotkey::{HotkeyEvent, HotkeyMonitor};
|
||||
use crate::insertion::TextInserter;
|
||||
use crate::persistence::{
|
||||
sync_style_pack_preferences, CorrectionRuleStore, CredentialAccount, CredentialsVault,
|
||||
DictionaryStore, HistoryStore, PreferencesStore, StylePackStore,
|
||||
sync_style_pack_preferences, ActivityStore, CorrectionRuleStore, CredentialAccount,
|
||||
CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore, StylePackStore,
|
||||
};
|
||||
|
||||
use crate::llm_gemini::{GeminiConfig, GeminiProvider};
|
||||
@@ -238,6 +238,8 @@ pub struct Coordinator {
|
||||
struct Inner {
|
||||
app: Mutex<Option<AppHandle>>,
|
||||
history: HistoryStore,
|
||||
/// 每日活动计数(热力图数据源),与 history 的保留策略解耦。
|
||||
activity: ActivityStore,
|
||||
prefs: PreferencesStore,
|
||||
style_packs: StylePackStore,
|
||||
vocab: DictionaryStore,
|
||||
@@ -305,6 +307,11 @@ struct Inner {
|
||||
/// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发
|
||||
/// resize / reposition。
|
||||
capsule_layout: Mutex<Option<CapsuleLayoutState>>,
|
||||
/// 预备态标志:按下热键即"乐观显示"胶囊(带入场动画),此时麦克风还在 cpal
|
||||
/// init 窗口内、没有第一帧 PCM。为 true 时 emit_capsule 把 Recording payload 的
|
||||
/// `warming` 打成 true(前端渲染"待命"光效);`level_handler` 首次触发(PCM 真的
|
||||
/// 流入)后置 false,光条"点亮"进入正式录音。begin_session 每次入场重置为 true。
|
||||
capsule_warming: AtomicBool,
|
||||
/// QA 用的 ASR 句柄。必须跟 active_asr_provider 保持一致,避免浮窗走不同入口。
|
||||
qa_asr: Mutex<Option<ActiveAsr>>,
|
||||
/// QA 用的 Recorder 句柄。
|
||||
@@ -383,10 +390,16 @@ impl Coordinator {
|
||||
CorrectionRuleStore::new_fallback()
|
||||
});
|
||||
|
||||
let activity = ActivityStore::load().unwrap_or_else(|e| {
|
||||
log::error!("[coord] ActivityStore init failed: {e}; 活动计数降级为内存态");
|
||||
ActivityStore::new_fallback()
|
||||
});
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
app: Mutex::new(None),
|
||||
history,
|
||||
activity,
|
||||
prefs,
|
||||
style_packs,
|
||||
vocab,
|
||||
@@ -415,6 +428,7 @@ impl Coordinator {
|
||||
last_capsule_state: Mutex::new(None),
|
||||
qa_state: Mutex::new(QaSessionState::default()),
|
||||
capsule_layout: Mutex::new(None),
|
||||
capsule_warming: AtomicBool::new(false),
|
||||
qa_asr: Mutex::new(None),
|
||||
qa_recorder: Mutex::new(None),
|
||||
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
@@ -474,10 +488,16 @@ impl Coordinator {
|
||||
CorrectionRuleStore::new_fallback()
|
||||
});
|
||||
|
||||
let activity = ActivityStore::load().unwrap_or_else(|e| {
|
||||
log::error!("[coord] ActivityStore init failed: {e}; 活动计数降级为内存态");
|
||||
ActivityStore::new_fallback()
|
||||
});
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
app: Mutex::new(None),
|
||||
history,
|
||||
activity,
|
||||
prefs,
|
||||
style_packs,
|
||||
vocab,
|
||||
@@ -508,6 +528,7 @@ impl Coordinator {
|
||||
last_capsule_state: Mutex::new(None),
|
||||
qa_state: Mutex::new(QaSessionState::default()),
|
||||
capsule_layout: Mutex::new(None),
|
||||
capsule_warming: AtomicBool::new(false),
|
||||
qa_asr: Mutex::new(None),
|
||||
qa_recorder: Mutex::new(None),
|
||||
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
@@ -1088,10 +1109,32 @@ impl Coordinator {
|
||||
dictation::resolve_less_computer_approval(token, approved);
|
||||
}
|
||||
|
||||
/// 浮窗打字输入:文字指令直接进入 Less Computer 执行链(与语音转写同一条
|
||||
/// 路径——同样的护栏钳制 / 审批循环 / 连续会话语义),跳过录音与 ASR。
|
||||
pub fn less_computer_submit_text(&self, text: String) {
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let inner = Arc::clone(&self.inner);
|
||||
tokio::spawn(async move {
|
||||
let session_id = crate::coordinator_state::new_session_id();
|
||||
if let Err(e) =
|
||||
dictation::run_voice_agent_transcript(&inner, session_id, text, 0).await
|
||||
{
|
||||
log::warn!("[less-computer] text submit run failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn history(&self) -> &HistoryStore {
|
||||
&self.inner.history
|
||||
}
|
||||
|
||||
pub fn activity(&self) -> &ActivityStore {
|
||||
&self.inner.activity
|
||||
}
|
||||
|
||||
pub fn prefs(&self) -> &PreferencesStore {
|
||||
&self.inner.prefs
|
||||
}
|
||||
|
||||
@@ -299,8 +299,16 @@ pub(super) async fn build_local_qwen3(
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn build_apple_speech() -> Arc<crate::asr::local::AppleSpeechAsr> {
|
||||
Arc::new(crate::asr::local::AppleSpeechAsr::new())
|
||||
pub(super) fn build_apple_speech(
|
||||
prefs: &crate::types::UserPreferences,
|
||||
) -> Arc<crate::asr::local::AppleSpeechAsr> {
|
||||
// Apple 识别 locale 跟随用户工作语言主语言 —— 不显式指定 SFSpeechRecognizer 就落到
|
||||
// 系统首选语言(常是英文),中文语音会被识别成英文且理解错误。未收录语言回退默认。
|
||||
let locale = prefs
|
||||
.working_languages
|
||||
.first()
|
||||
.and_then(|name| crate::asr::local::native_name_to_apple_locale(name));
|
||||
Arc::new(crate::asr::local::AppleSpeechAsr::new(locale))
|
||||
}
|
||||
|
||||
/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露
|
||||
@@ -496,7 +504,7 @@ pub(super) async fn build_qa_asr_start(inner: &Arc<Inner>, active_asr: &str) ->
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if crate::asr::local::is_apple_speech(active_asr) {
|
||||
let local = build_apple_speech();
|
||||
let local = build_apple_speech(&inner.prefs.get());
|
||||
let active = ActiveAsr::AppleSpeech(Arc::clone(&local));
|
||||
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
|
||||
return Ok(QaAsrStart::Ready { active, consumer });
|
||||
|
||||
@@ -387,11 +387,16 @@ pub(super) fn emit_capsule(
|
||||
inserted_chars: Option<u32>,
|
||||
) {
|
||||
// 在 app 句柄校验之前记录,便于无 GUI 的测试断言「按下热键 → 弹了哪种胶囊」。
|
||||
*inner.last_capsule_state.lock() = Some(state);
|
||||
// replace 顺带取回上一帧 state,用于判断本次是不是「入场帧」(见下方 defer_capsule_emit)。
|
||||
let prev_state = inner.last_capsule_state.lock().replace(state);
|
||||
let app_opt = inner.app.lock().clone();
|
||||
let Some(app) = app_opt else { return };
|
||||
let translation = inner.translation_modifier_seen.load(Ordering::SeqCst);
|
||||
let operating = inner.state.lock().voice_agent;
|
||||
// 预备态只对 Recording 有意义:麦克风还没吐第一帧 PCM 时(capsule_warming=true)把
|
||||
// warming 打成 true,前端渲染「待命」光效;level_handler 首触发后翻 false → 光条点亮。
|
||||
let warming = matches!(state, CapsuleState::Recording)
|
||||
&& inner.capsule_warming.load(Ordering::SeqCst);
|
||||
let payload = CapsulePayload {
|
||||
state,
|
||||
level,
|
||||
@@ -400,6 +405,7 @@ pub(super) fn emit_capsule(
|
||||
inserted_chars,
|
||||
translation,
|
||||
operating,
|
||||
warming,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -409,6 +415,13 @@ pub(super) fn emit_capsule(
|
||||
// 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread
|
||||
// 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。
|
||||
let visible = !matches!(state, CapsuleState::Idle);
|
||||
// 入场帧:胶囊从不可见第一次变可见。按平时的「同步 emit + 异步 show」,前端会在窗口
|
||||
// 还隐藏时就起播 capsule-in,等窗口真 show 出来动画早已播完 → 用户看到胶囊「凭空出
|
||||
// 现」而非「滑入」。修法:入场帧把发给 capsule 窗口的事件推迟到主线程闭包里、
|
||||
// window.show 之后再 emit,保证前端起播入场动画时窗口已可见、动画完整可见。Linux 不
|
||||
// 走胶囊窗口(文字经 fcitx5 直接 commit),保持原同步 emit 不变。
|
||||
let was_visible = matches!(prev_state, Some(s) if !matches!(s, CapsuleState::Idle));
|
||||
let defer_capsule_emit = visible && !was_visible && cfg!(not(target_os = "linux"));
|
||||
|
||||
// Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。
|
||||
// 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。
|
||||
@@ -509,6 +522,13 @@ pub(super) fn emit_capsule(
|
||||
// 闪烁。pr_agent 关注点 — 见 audit follow-up。
|
||||
let inner_for_main = Arc::clone(inner);
|
||||
let app_for_main = app.clone();
|
||||
// 入场帧要在 window.show 之后、闭包内部把 state 回发给前端,需要 payload 的独立副本
|
||||
// move 进闭包;非入场帧走闭包外的即时同步 emit(下方),这里就是 None。
|
||||
let payload_for_deferred_emit = if defer_capsule_emit {
|
||||
Some(payload.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
let Some(window) = app_for_main.get_webview_window("capsule") else {
|
||||
// #470 诊断 v2:比 A/B/C 更靠前的暗点 A0 —— capsule webview 句柄取不到
|
||||
@@ -568,12 +588,22 @@ pub(super) fn emit_capsule(
|
||||
hide_capsule_window_if_present();
|
||||
let _ = window.hide();
|
||||
}
|
||||
// 入场帧:窗口刚 show(或本次用户关了胶囊显示走了 hide 分支),此刻再把 state 发给
|
||||
// capsule 前端 —— 前端起播 capsule-in 时窗口已可见,入场动画从头完整播放。
|
||||
if let Some(payload) = payload_for_deferred_emit.as_ref() {
|
||||
let _ = app_for_main.emit_to("capsule", "capsule:state", payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app.emit_to("capsule", "capsule:state", &payload);
|
||||
// 非入场帧(含 Linux、录音中的 level 更新、离场/终态)保持即时同步 emit,最低延迟;
|
||||
// 入场帧已在上面的主线程闭包里、window.show 之后 emit 过,这里跳过避免重复下发。
|
||||
if !defer_capsule_emit {
|
||||
let _ = app.emit_to("capsule", "capsule:state", &payload);
|
||||
}
|
||||
// 主窗口也需要 capsule:state 事件:AudioCueListener 用它触发录音提示音。
|
||||
// Linux 上胶囊隐藏时提示音仍应工作,所以同时发给 main 窗口。
|
||||
// Linux 上胶囊隐藏时提示音仍应工作,所以同时发给 main 窗口。始终即时,与胶囊窗口
|
||||
// 显示时机解耦。
|
||||
let _ = app.emit_to("main", "capsule:state", &payload);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,9 +95,10 @@ fn emit_less_computer(inner: &Arc<Inner>, payload: serde_json::Value) {
|
||||
/// - 失败:`(raw_text, Some(reason), false)` — 流式过程出错,调用方走 raw 一次性兜底
|
||||
/// - 不支持:`run_streaming_polish` 内部直接调 `polish_or_passthrough` 透明降级
|
||||
///
|
||||
/// **不在流式路径里做**:`apply_chinese_script_preference` / `apply_correction_rules`
|
||||
/// 这两步在 v1 跳过 —— 字符已经一边流一边落出去了,不好回退。需要的话只能关 toggle 走
|
||||
/// 一次性路径。
|
||||
/// **流式路径里的字形转换**:Simplified(t2s)在 `on_delta` 对每个 delta 就地转换
|
||||
/// (近乎逐字映射,跨 delta 拆散词条也几乎总是正确);Traditional(s2t)有真歧义,
|
||||
/// `streaming_insert_eligible` 仍把它挡在一次性路径。`apply_correction_rules` 依旧
|
||||
/// 不在流式路径里做 —— 字符已经落出去,不好回退。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_streaming_polish(
|
||||
inner: &Arc<Inner>,
|
||||
@@ -193,6 +194,18 @@ async fn run_streaming_polish(
|
||||
// 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。
|
||||
let inner_for_cancel = Arc::clone(inner);
|
||||
let should_cancel = move || inner_for_cancel.state.lock().cancelled;
|
||||
// Simplified 目标:对每个 delta 就地 t2s(转换器建一次,避免每个 delta 重新加载
|
||||
// 词典)。Traditional 不会走到这里(eligibility 已降级),Auto 无需转换。
|
||||
let delta_converter = (chinese_script_preference
|
||||
== crate::types::ChineseScriptPreference::Simplified)
|
||||
.then(|| {
|
||||
ferrous_opencc::OpenCC::from_config(ferrous_opencc::config::BuiltinConfig::T2s)
|
||||
.map_err(|e| {
|
||||
log::warn!("[coord] streaming_insert: OpenCC t2s init failed, deltas stay unconverted: {e}");
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
let outcome = super::polish_or_passthrough_streaming(
|
||||
raw,
|
||||
mode,
|
||||
@@ -205,7 +218,11 @@ async fn run_streaming_polish(
|
||||
front_app,
|
||||
prior_turns,
|
||||
move |delta: &str| {
|
||||
let _ = tx.send(delta.to_string());
|
||||
let converted = match delta_converter.as_ref() {
|
||||
Some(converter) => converter.convert(delta),
|
||||
None => delta.to_string(),
|
||||
};
|
||||
let _ = tx.send(converted);
|
||||
},
|
||||
should_cancel,
|
||||
)
|
||||
@@ -521,10 +538,13 @@ fn streaming_insert_eligible(
|
||||
streaming_insert_enabled
|
||||
&& !translation_active
|
||||
&& (mode != PolishMode::Raw || raw_uses_llm)
|
||||
// 非 Auto 字形(简/繁)要对成品文本做确定性 OpenCC 转换,而流式是边出边落字、
|
||||
// 没有成品可后处理(finalize_polished_text 在 already_streamed 时直接 return)。
|
||||
// → 非 Auto 时关掉流式,走一次性路径,确保简/繁转换真正生效(issue #643)。
|
||||
&& chinese_script_preference == crate::types::ChineseScriptPreference::Auto
|
||||
// 固定字形的 OpenCC 转换与流式的兼容性按方向区分:
|
||||
// - Simplified(t2s):近乎逐字映射,对每个 delta 就地转换即可(跨 delta
|
||||
// 边界拆散的词级条目退化为逐字转换,t2s 方向仍几乎总是正确),流式放行
|
||||
// —— 否则固定简体的用户流式静默失效且无从得知原因。
|
||||
// - Traditional(s2t):一简对多繁有真歧义(发→發/髮),需要全文上下文,
|
||||
// 仍走一次性路径确保转换准确(issue #643)。
|
||||
&& chinese_script_preference != crate::types::ChineseScriptPreference::Traditional
|
||||
&& windows_insertion_allows_streaming(windows_insertion_mode)
|
||||
}
|
||||
|
||||
@@ -687,7 +707,9 @@ pub(super) async fn handle_released(inner: &Arc<Inner>) {
|
||||
}
|
||||
|
||||
/// Less Computer 收尾:把转写当作指令交给无头 Claude,结果以胶囊展示(不插入到光标)。
|
||||
async fn run_voice_agent_transcript(
|
||||
/// pub(super):除语音路径外,浮窗的打字输入(less_computer_submit_text 命令)
|
||||
/// 也以文字直接进入同一条执行链(同样的护栏 / 审批 / 连续会话语义)。
|
||||
pub(super) async fn run_voice_agent_transcript(
|
||||
inner: &Arc<Inner>,
|
||||
_session_id: SessionId,
|
||||
transcript: String,
|
||||
@@ -1031,6 +1053,9 @@ async fn run_less_computer_once(
|
||||
E::ToolUse { name, .. } => {
|
||||
emit_less_computer(inner, serde_json::json!({ "kind": "tool", "name": name }));
|
||||
}
|
||||
E::Compaction { .. } => {
|
||||
emit_less_computer(inner, serde_json::json!({ "kind": "compaction" }));
|
||||
}
|
||||
E::Completed {
|
||||
text, cost_usd: c, ..
|
||||
} => {
|
||||
@@ -1218,6 +1243,14 @@ pub(super) async fn begin_session_as(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 乐观显示:按下热键即弹出胶囊并播入场动画,不等麦克风/ASR。此刻麦克风还在 cpal
|
||||
// init 窗口内、没有第一帧 PCM,先进「预备态」(warming=true → 前端渲染待命光效,引导
|
||||
// 用户稍候再开口);level_handler 首次触发(PCM 真的流入)后翻成正式录音态、光条点亮。
|
||||
// 这样把「视觉反馈」与「麦克风就绪」解耦:即时反馈 + 完整入场动画,同时用预备→点亮的
|
||||
// 过渡守住「不漏首字」。若随后凭证/权限校验失败,下面分支会用 Error 覆盖这一帧。
|
||||
inner.capsule_warming.store(true, Ordering::SeqCst);
|
||||
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
|
||||
|
||||
if let Err(message) = ensure_asr_credentials() {
|
||||
log::warn!("[coord] ASR credential gate failed: {message}");
|
||||
emit_capsule(
|
||||
@@ -1384,7 +1417,7 @@ pub(super) async fn begin_session_as(
|
||||
.await?;
|
||||
}
|
||||
MacosKeylessDictationProvider::AppleSpeech => {
|
||||
let local = build_apple_speech();
|
||||
let local = build_apple_speech(&inner.prefs.get());
|
||||
store_asr_for_session(
|
||||
inner,
|
||||
current_session_id,
|
||||
@@ -1629,6 +1662,10 @@ pub(super) async fn start_recorder_for_starting(
|
||||
.started_at
|
||||
.elapsed()
|
||||
.as_millis() as u64;
|
||||
// 第一帧 PCM 真的流到 consumer 了(recorder.rs::process_callback 的顺序保证
|
||||
// consume_pcm_chunk 先于 level_handler)——关掉预备态,让这一帧起 payload.warming
|
||||
// 翻 false,前端把「待命」光条点亮成正式录音态。之后每帧都是 false(幂等)。
|
||||
inner_for_level.capsule_warming.store(false, Ordering::SeqCst);
|
||||
emit_capsule(
|
||||
&inner_for_level,
|
||||
CapsuleState::Recording,
|
||||
@@ -2729,6 +2766,14 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
) {
|
||||
log::error!("[coord] history append failed: {e}");
|
||||
}
|
||||
// 活动计数(概览页热力图数据源):只有成功完成的听写才点亮格子——转录失败 /
|
||||
// 错误收尾的两处 append 不计。写失败不阻断主流程。
|
||||
if let Err(e) = inner
|
||||
.activity
|
||||
.bump(&chrono::Local::now().format("%Y-%m-%d").to_string())
|
||||
{
|
||||
log::warn!("[coord] activity bump failed: {e}");
|
||||
}
|
||||
|
||||
// 远程输入:把本次最终文字回传给手机端。remote_server 的 WS handler 订阅了
|
||||
// "remote:result"(mod.rs:614),但此前全仓从未 emit,导致手机结果区永远空(#691)。
|
||||
@@ -3268,30 +3313,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_disabled_for_non_auto_script_so_opencc_runs() {
|
||||
// issue #643:非 Auto 字形(简/繁)必须走一次性路径,让 finalize 的 OpenCC 转换生效。
|
||||
for pref in [
|
||||
ChineseScriptPreference::Simplified,
|
||||
fn streaming_script_gate_blocks_only_traditional() {
|
||||
// Traditional(s2t)有一简对多繁的真歧义,必须走一次性路径做全文 OpenCC
|
||||
// 转换(issue #643);Simplified(t2s)近乎逐字,on_delta 就地转换即可,
|
||||
// 不再挡流式(用户反馈:固定简体导致流式静默失效)。
|
||||
assert!(!streaming_insert_eligible(
|
||||
true,
|
||||
false,
|
||||
PolishMode::Light,
|
||||
false,
|
||||
ChineseScriptPreference::Traditional,
|
||||
crate::types::WindowsInsertionMode::SendInput,
|
||||
));
|
||||
for pref in [
|
||||
ChineseScriptPreference::Auto,
|
||||
ChineseScriptPreference::Simplified,
|
||||
] {
|
||||
assert!(!streaming_insert_eligible(
|
||||
assert!(streaming_insert_eligible(
|
||||
true,
|
||||
false,
|
||||
PolishMode::Light,
|
||||
false,
|
||||
pref,
|
||||
crate::types::WindowsInsertionMode::Tsf,
|
||||
crate::types::WindowsInsertionMode::SendInput,
|
||||
));
|
||||
}
|
||||
// Auto + SendInput 仍可流式。
|
||||
assert!(streaming_insert_eligible(
|
||||
true,
|
||||
false,
|
||||
PolishMode::Light,
|
||||
false,
|
||||
ChineseScriptPreference::Auto,
|
||||
crate::types::WindowsInsertionMode::SendInput,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -690,6 +690,10 @@ pub(super) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// QA 无「预备态」语义(不走等麦克风预热的乐观显示),显式清掉 capsule_warming ——
|
||||
// 否则若上一次听写在拿到首帧 PCM 前异常早退、warming 停在 true,这里的 QA 录音胶囊会
|
||||
// 读到陈旧 true 卡在「待命」收拢态(QA 的 level_handler 不翻这个标志)。审核 follow-up。
|
||||
inner.capsule_warming.store(false, Ordering::SeqCst);
|
||||
// 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…"
|
||||
// 的视觉反馈跟主听写完全一致。
|
||||
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
|
||||
|
||||
@@ -166,6 +166,7 @@ macro_rules! app_invoke_handler_desktop {
|
||||
commands::list_history,
|
||||
commands::delete_history_entry,
|
||||
commands::clear_history,
|
||||
commands::get_activity_stats,
|
||||
commands::read_audio_recording,
|
||||
commands::retranscribe_recording,
|
||||
commands::marketplace_list,
|
||||
@@ -232,6 +233,7 @@ macro_rules! app_invoke_handler_desktop {
|
||||
commands::qa_window_pin,
|
||||
commands::less_computer_window_dismiss,
|
||||
commands::less_computer_window_resize,
|
||||
commands::less_computer_submit_text,
|
||||
commands::less_computer_approve,
|
||||
commands::validate_combo_hotkey,
|
||||
commands::set_combo_hotkey,
|
||||
@@ -331,6 +333,7 @@ macro_rules! app_invoke_handler_mobile {
|
||||
$crate::commands::list_history,
|
||||
$crate::commands::delete_history_entry,
|
||||
$crate::commands::clear_history,
|
||||
$crate::commands::get_activity_stats,
|
||||
$crate::commands::read_audio_recording,
|
||||
$crate::commands::retranscribe_recording,
|
||||
$crate::commands::marketplace_list,
|
||||
@@ -509,6 +512,11 @@ fn run_desktop() {
|
||||
Err(e) => log::warn!("[capsule] to_panel failed: {e:?}"),
|
||||
}
|
||||
}
|
||||
// 纯光效舞台没有任何可点元素(✕/✓ 按钮已移除),而窗口放大到 460×180
|
||||
// 盖住屏幕底部中央 —— 必须鼠标穿透,否则会挡住底下应用的点击。
|
||||
if let Err(e) = capsule.set_ignore_cursor_events(true) {
|
||||
log::warn!("[capsule] set_ignore_cursor_events failed: {e}");
|
||||
}
|
||||
if let Err(e) = position_capsule_bottom_center(&capsule, false) {
|
||||
log::warn!("[capsule] position failed: {e}");
|
||||
}
|
||||
@@ -2080,11 +2088,21 @@ fn make_qa_window_draggable_macos<R: tauri::Runtime>(window: &tauri::WebviewWind
|
||||
log::warn!("[qa] ns_window null; drag fix skipped");
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let _: () = msg_send![ns_window, setMovableByWindowBackground: Bool::YES];
|
||||
let _: () = msg_send![ns_window, setMovable: Bool::YES];
|
||||
// 异常兜底:AppKit 在 drag margins 状态不一致时会从 setMovable 内部 raise
|
||||
// NSException;不捕获的话异常穿过 Rust 边界直接 abort 整个 app。捕获后降级为
|
||||
// 日志(拖动失效但 app 活着),日志会指认具体异常便于追根因。
|
||||
// SAFETY: 闭包内只有两个无返回值的 ObjC 消息发送,没有需要运行析构的 Rust 值,
|
||||
// 异常展开跳过闭包帧不会破坏内存安全。
|
||||
let result = unsafe {
|
||||
objc2::exception::catch(std::panic::AssertUnwindSafe(|| {
|
||||
let _: () = msg_send![ns_window, setMovableByWindowBackground: Bool::YES];
|
||||
let _: () = msg_send![ns_window, setMovable: Bool::YES];
|
||||
}))
|
||||
};
|
||||
match result {
|
||||
Ok(()) => log::info!("[qa] NSWindow movableByWindowBackground=YES"),
|
||||
Err(e) => log::error!("[qa] drag setup raised ObjC exception (caught, drag disabled): {e:?}"),
|
||||
}
|
||||
log::info!("[qa] NSWindow movableByWindowBackground=YES");
|
||||
}
|
||||
|
||||
/// 懒创建 QA 浮窗:原来在 tauri.conf.json eager 创建(常驻一个 WebKit 进程)。改为首次
|
||||
@@ -2112,8 +2130,15 @@ fn ensure_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<tauri::Webv
|
||||
.build();
|
||||
match built {
|
||||
Ok(w) => {
|
||||
// ⚠️ NSWindow 操作必须在主线程(macOS 26 硬约束)。ensure_qa_window 常从
|
||||
// tokio worker 进来(热键桥接循环),在 worker 线程直接 setMovable 会让
|
||||
// AppKit 从 _postWindowNeedsToResetDragMargins 抛 NSException 直接崩掉
|
||||
// 整个 app(2026-07-03 四次同栈崩溃的根因)——dispatch 回主线程执行。
|
||||
#[cfg(target_os = "macos")]
|
||||
make_qa_window_draggable_macos(&w);
|
||||
{
|
||||
let w_clone = w.clone();
|
||||
let _ = app.run_on_main_thread(move || make_qa_window_draggable_macos(&w_clone));
|
||||
}
|
||||
Some(w)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -2625,42 +2650,21 @@ struct CapsuleWindowBounds {
|
||||
}
|
||||
|
||||
fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
const WINDOWS_CAPSULE_PILL_WIDTH: f64 = 196.0;
|
||||
const WINDOWS_CAPSULE_SIDE_INSET: f64 = 12.0;
|
||||
CapsuleWindowBounds {
|
||||
// Keep the existing Windows hitbox width, but express it as
|
||||
// pill width (196) + symmetric 12px side insets for shadow room.
|
||||
width: WINDOWS_CAPSULE_PILL_WIDTH + WINDOWS_CAPSULE_SIDE_INSET * 2.0,
|
||||
height: if translation_active { 118.0 } else { 84.0 },
|
||||
bottom_inset: 12.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// macOS / Linux:固定 220×110,与 1.2.11 行为一致 — 录音 / 翻译徽章
|
||||
// 共用同一个窗口尺寸,避免按 Shift 后窗口高度变化导致胶囊整体下移。
|
||||
let _ = translation_active;
|
||||
CapsuleWindowBounds {
|
||||
width: 220.0,
|
||||
height: 110.0,
|
||||
bottom_inset: 0.0,
|
||||
}
|
||||
// 纯光效语音舞台(siri-glsl 原始比例):光条横贯 ~420px + 发光扩散余量。
|
||||
// 与前端 src/lib/capsuleLayout.ts 的 VOICE_ORB_STAGE_* 保持一致。
|
||||
let _ = translation_active;
|
||||
CapsuleWindowBounds {
|
||||
width: 460.0,
|
||||
height: 180.0,
|
||||
bottom_inset: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn capsule_visual_height(_translation_active: bool) -> f64 {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
52.0
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
96.0
|
||||
}
|
||||
// 故意小于窗口高(180):窗口整体下沉 40px,让光条视觉中心落回原胶囊的高度
|
||||
// 附近(bottom_visual_position 以此值算窗口顶 y = 屏底 - 80 - visual_height)。
|
||||
// 底部 40px 只是发光余量,窗口透明 + 鼠标穿透,压到 Dock 上方无碍。
|
||||
140.0
|
||||
}
|
||||
|
||||
fn capsule_height_for_qa() -> f64 {
|
||||
@@ -2729,53 +2733,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capsule_window_bounds_leave_room_for_windows_shadow() {
|
||||
fn capsule_window_bounds_match_voice_orb_stage() {
|
||||
let bounds = capsule_window_bounds(false);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
(bounds.width, bounds.height, bounds.bottom_inset),
|
||||
(220.0, 84.0, 12.0)
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
(bounds.width, bounds.height, bounds.bottom_inset),
|
||||
(220.0, 110.0, 0.0)
|
||||
(460.0, 180.0, 0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capsule_window_bounds_expand_for_translation_badge() {
|
||||
fn capsule_window_bounds_stay_fixed_for_translation_badge() {
|
||||
let bounds = capsule_window_bounds(true);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
(bounds.width, bounds.height, bounds.bottom_inset),
|
||||
(220.0, 118.0, 12.0)
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
(bounds.width, bounds.height, bounds.bottom_inset),
|
||||
(220.0, 110.0, 0.0)
|
||||
(460.0, 180.0, 0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capsule_visual_height_matches_frontend_pill() {
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(capsule_visual_height(true), 52.0);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(capsule_visual_height(true), 96.0);
|
||||
fn capsule_visual_height_sinks_stage_toward_dock() {
|
||||
assert_eq!(capsule_visual_height(true), 140.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qa_anchor_uses_normal_capsule_height_source() {
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(capsule_height_for_qa(), 52.0);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(capsule_height_for_qa(), 96.0);
|
||||
assert_eq!(capsule_height_for_qa(), 140.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! 每日听写活动计数(`date(YYYY-MM-DD) → count`),供概览页年度活动热力图使用。
|
||||
//!
|
||||
//! 与历史内容存储完全解耦:不含任何转写文本,也不受历史保留策略 / 条数上限影响
|
||||
//! —— 清理历史不会抹掉活动足迹,热力图因此能覆盖全年而无需放开历史上限
|
||||
//! (取代 PR #716 里「为热力图把历史改为无限保留」的方案)。
|
||||
//! 写入时按保留窗口(两年)裁剪最早的日期,文件天然有界。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::{atomic_write, data_dir, ensure_dir, read_or_default};
|
||||
|
||||
const ACTIVITY_FILE: &str = "activity.json";
|
||||
/// 保留最近两年(含闰年余量)的日计数,超窗的最早日期在写入时移除。
|
||||
const ACTIVITY_RETENTION_DAYS: usize = 731;
|
||||
|
||||
pub struct ActivityStore {
|
||||
path: PathBuf,
|
||||
cache: Mutex<BTreeMap<String, u32>>,
|
||||
}
|
||||
|
||||
impl ActivityStore {
|
||||
pub fn load() -> Result<Self> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
let path = dir.join(ACTIVITY_FILE);
|
||||
let cache: BTreeMap<String, u32> = read_or_default(&path)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
cache: Mutex::new(cache),
|
||||
})
|
||||
}
|
||||
|
||||
/// load 失败时的内存降级:计数仍可累加(本次运行内有效),写盘静默失败。
|
||||
/// 活动计数是非关键路径,不因它阻断听写初始化。
|
||||
pub fn new_fallback() -> Self {
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
cache: Mutex::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录一次活动。`date` 为本地日期 `YYYY-MM-DD`(BTreeMap 按字典序即按日期序)。
|
||||
pub fn bump(&self, date: &str) -> Result<()> {
|
||||
let mut cache = self.cache.lock();
|
||||
*cache.entry(date.to_string()).or_insert(0) += 1;
|
||||
while cache.len() > ACTIVITY_RETENTION_DAYS {
|
||||
let oldest = match cache.keys().next() {
|
||||
Some(key) => key.clone(),
|
||||
None => break,
|
||||
};
|
||||
cache.remove(&oldest);
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&*cache)?;
|
||||
atomic_write(&self.path, &bytes)
|
||||
}
|
||||
|
||||
/// 全量快照(日期升序),前端聚合成热力图。
|
||||
pub fn snapshot(&self) -> Vec<(String, u32)> {
|
||||
self.cache
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|(date, count)| (date.clone(), *count))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -451,13 +451,62 @@ fn read_chunk_manifest(json: &str) -> Option<CredsChunkManifest> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows Credential Manager (`CredReadW`) can transiently fail right after
|
||||
/// login / under contention when we read the manifest entry plus every chunk
|
||||
/// entry in quick succession. A single failed read makes the whole credential
|
||||
/// set look empty → `load_keyring_credentials` returns `Err` → `load_credentials`
|
||||
/// falls back to an empty default → Overview shows「火山引擎未配置」even though the
|
||||
/// secrets are present (the next dictation re-reads and succeeds, which is why the
|
||||
/// bug is *probabilistic* and the app "实际可以正常使用"). The more chunks a
|
||||
/// credential set spans, the more reads per load, the higher the odds at least
|
||||
/// one trips. Retry transient errors a few times with short backoff.
|
||||
///
|
||||
/// macOS / Linux keep the original single-shot behavior on purpose: their read
|
||||
/// errors are ACL denials that won't heal on retry, and the un-cached error path
|
||||
/// already retries on the next call — adding sleeps there would only slow the
|
||||
/// macOS first-launch Keychain authorization flow.
|
||||
#[cfg(target_os = "windows")]
|
||||
const KEYRING_READ_RETRY_ATTEMPTS: usize = 4;
|
||||
#[cfg(target_os = "windows")]
|
||||
const KEYRING_READ_RETRY_BACKOFF_MS: u64 = 60;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn get_keyring_password(account: &str) -> Result<Option<String>> {
|
||||
match keyring_entry_for(account)?.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => {
|
||||
Err(anyhow!(e)).with_context(|| format!("read system credential vault {account}"))
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
match keyring_entry_for(account)?.get_password() {
|
||||
Ok(value) => return Ok(Some(value)),
|
||||
// NoEntry is a definitive "not stored" answer, never a transient
|
||||
// failure — return immediately so genuinely-unconfigured providers
|
||||
// don't pay the retry latency.
|
||||
Err(keyring::Error::NoEntry) => return Ok(None),
|
||||
Err(e) => {
|
||||
attempt += 1;
|
||||
if attempt >= KEYRING_READ_RETRY_ATTEMPTS {
|
||||
return Err(anyhow!(e))
|
||||
.with_context(|| format!("read system credential vault {account}"));
|
||||
}
|
||||
log::warn!(
|
||||
"[vault] transient credential read for {account} failed \
|
||||
(attempt {attempt}/{KEYRING_READ_RETRY_ATTEMPTS}): {e}; retrying"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(
|
||||
KEYRING_READ_RETRY_BACKOFF_MS * attempt as u64,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
match keyring_entry_for(account)?.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => {
|
||||
Err(anyhow!(e)).with_context(|| format!("read system credential vault {account}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod activity;
|
||||
mod correction;
|
||||
mod credentials;
|
||||
mod dictionary;
|
||||
@@ -32,6 +33,7 @@ mod paths;
|
||||
mod preferences;
|
||||
mod style_pack;
|
||||
|
||||
pub use activity::*;
|
||||
pub use correction::*;
|
||||
pub use credentials::*;
|
||||
pub use dictionary::*;
|
||||
|
||||
@@ -127,6 +127,14 @@ pub enum InsertStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 概览页年度活动热力图的单日计数(date = 本地日期 YYYY-MM-DD)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActivityDay {
|
||||
pub date: String,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DictationSession {
|
||||
@@ -819,6 +827,10 @@ pub struct UserPreferences {
|
||||
/// 默认 true(更接近用户习惯)。
|
||||
#[serde(default = "default_true")]
|
||||
pub streaming_insert_save_clipboard: bool,
|
||||
/// 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片,
|
||||
/// 活动计数照常记录(persistence/activity.rs),再打开时全年数据仍在。
|
||||
#[serde(default = "default_true")]
|
||||
pub show_overview_activity_heatmap: bool,
|
||||
/// 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。
|
||||
/// Android 开启后自动检查并下载,校验后打开系统安装器;桌面仅自动检查 + 用户确认安装。
|
||||
/// 关闭后仅 Settings 手动「检查更新」按钮可用。
|
||||
@@ -1039,6 +1051,8 @@ struct UserPreferencesWire {
|
||||
#[serde(default = "default_true")]
|
||||
streaming_insert_save_clipboard: bool,
|
||||
#[serde(default = "default_true")]
|
||||
show_overview_activity_heatmap: bool,
|
||||
#[serde(default = "default_true")]
|
||||
auto_update_check: bool,
|
||||
#[serde(default)]
|
||||
history_max_entries: Option<u32>,
|
||||
@@ -1133,6 +1147,7 @@ impl Default for UserPreferencesWire {
|
||||
streaming_insert: prefs.streaming_insert,
|
||||
streaming_insert_default_migrated: prefs.streaming_insert_default_migrated,
|
||||
streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard,
|
||||
show_overview_activity_heatmap: prefs.show_overview_activity_heatmap,
|
||||
auto_update_check: prefs.auto_update_check,
|
||||
history_max_entries: prefs.history_max_entries,
|
||||
record_audio_for_debug: prefs.record_audio_for_debug,
|
||||
@@ -1251,6 +1266,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
streaming_insert,
|
||||
streaming_insert_default_migrated: true,
|
||||
streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard,
|
||||
show_overview_activity_heatmap: wire.show_overview_activity_heatmap,
|
||||
auto_update_check: wire.auto_update_check,
|
||||
history_max_entries: wire.history_max_entries,
|
||||
record_audio_for_debug: wire.record_audio_for_debug,
|
||||
@@ -1991,6 +2007,7 @@ impl Default for UserPreferences {
|
||||
streaming_insert: true,
|
||||
streaming_insert_default_migrated: true,
|
||||
streaming_insert_save_clipboard: true,
|
||||
show_overview_activity_heatmap: true,
|
||||
auto_update_check: true,
|
||||
history_max_entries: None,
|
||||
record_audio_for_debug: false,
|
||||
@@ -2658,6 +2675,12 @@ pub struct CapsulePayload {
|
||||
/// 从 "thinking" 换成 "using"——告诉用户 Agent 正在操作电脑而非单纯思考。
|
||||
#[serde(default)]
|
||||
pub operating: bool,
|
||||
/// 预备态:胶囊已经"乐观显示"出来(按下热键即弹出并播入场动画),但麦克风还没
|
||||
/// 真正开始 capture 第一帧 PCM。为 true 时前端渲染"待命"光效(柔和呼吸、不接真实
|
||||
/// 电平),并暗示用户先别急着开口;`level_handler` 首次触发(PCM 真的流入)后翻成
|
||||
/// false,光条"点亮"进入正式录音态。只对 Recording 状态有意义。详见胶囊出现时序改造。
|
||||
#[serde(default)]
|
||||
pub warming: bool,
|
||||
}
|
||||
|
||||
/// Snapshot of credentials read from vault — only what the UI needs to know
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenLess",
|
||||
"version": "1.3.13-Beta.1",
|
||||
"version": "1.3.14",
|
||||
"identifier": "com.openless.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -34,8 +34,8 @@
|
||||
"label": "capsule",
|
||||
"url": "index.html?window=capsule",
|
||||
"title": "OpenLess Capsule",
|
||||
"width": 220,
|
||||
"height": 110,
|
||||
"width": 460,
|
||||
"height": 180,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"shadow": false,
|
||||
|
||||
@@ -53,7 +53,7 @@ const ANDROID_SETUP_WIZARD_COMPLETE_KEY = 'openless.androidSetupWizardComplete';
|
||||
|
||||
export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) {
|
||||
if (isCapsule) {
|
||||
return <Capsule />;
|
||||
return <Capsule os={forcedOs} />;
|
||||
}
|
||||
if (isQa) {
|
||||
return (
|
||||
|
||||
@@ -303,7 +303,7 @@ export function UpdateDialog({
|
||||
const androidInstalled = isAndroid() && status === 'downloaded';
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.18)', display: 'grid', placeItems: 'center', zIndex: 40 }}>
|
||||
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: '0 18px 42px rgba(0,0,0,0.18)', padding: 18 }}>
|
||||
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: 'var(--ol-shadow-lg)', padding: 18 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{androidInstalled
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { detectOS, type OS } from './WindowChrome';
|
||||
import { SiriGL, warmUpSiriShaders } from './SiriGL';
|
||||
import {
|
||||
getCapsuleHostMetrics,
|
||||
getCapsuleMessageLayout,
|
||||
getCapsulePillMetrics,
|
||||
} from '../lib/capsuleLayout';
|
||||
import { invokeOrMock, isTauri } from '../lib/ipc';
|
||||
import { isTauri } from '../lib/ipc';
|
||||
import type { CapsulePayload, CapsuleState } from '../lib/types';
|
||||
|
||||
// 胶囊 keyframes 注入一次到 document.head,而不是放在组件 JSX 里。否则录音时音量
|
||||
// 每帧(~60Hz)setLevel 都会让 React 重新创建/reconcile 这个 <style> 元素 —— 纯属
|
||||
// 浪费,因为这些 keyframes 是静态的。与 QaPanel / LessComputerPanel 注入方式一致。
|
||||
const CAPSULE_KEYFRAMES = `
|
||||
/* 入场:从中央很窄的一小条(scaleX 0.18)+ 略压扁(scaleY 0.95)+ 透明,
|
||||
长出到 scaleX 1 / scaleY 1 / 不透明。配合 wrapper 的 transformOrigin:center,
|
||||
视觉上是「从中心向左右展开」。 */
|
||||
@keyframes capsule-in {
|
||||
from { opacity: 0; transform: scale(.78) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
from { opacity: 0; transform: scale(.46) translateY(18px); }
|
||||
70% { opacity: 1; transform: scale(1.035) translateY(-1px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
/* 离场:scaleX 由 1 收回 0.18 + 整体向下偏移 8px + 淡出。
|
||||
forwards 让最终帧(opacity:0、scaleX:.18)保持到组件被卸载。 */
|
||||
@keyframes capsule-out {
|
||||
from { opacity: 1; transform: scaleX(1) translateY(0); }
|
||||
to { opacity: 0; transform: scaleX(.18) translateY(8px); }
|
||||
from { opacity: 1; transform: scale(1) translateY(0); }
|
||||
to { opacity: 0; transform: scale(.46) translateY(18px); }
|
||||
}
|
||||
@keyframes cap-shine {
|
||||
0% { background-position: 200% center; }
|
||||
100% { background-position: -200% center; }
|
||||
}
|
||||
@keyframes cap-state-enter {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
/* 思考圆点(fluid dots)出场:纯淡入接住光条汇聚成的光点 —— 圆点自身的
|
||||
「从圆心散开」动画由 shader 的 uGather 驱动,这里不做任何缩放冲击。 */
|
||||
@keyframes siri-orb-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -43,310 +37,214 @@ if (typeof document !== 'undefined' && !document.getElementById('capsule-keyfram
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
interface AudioBarsProps {
|
||||
level: number;
|
||||
}
|
||||
|
||||
function AudioBars({ level }: AudioBarsProps) {
|
||||
const envelope = [0.55, 0.85, 1.0, 0.85, 0.55];
|
||||
const base = 2;
|
||||
const max = 24;
|
||||
const voice = Math.min(1, Math.max(0, level));
|
||||
const silenceGate = 0.012;
|
||||
const responseCeiling = 0.34;
|
||||
const gatedVoice = Math.min(1, Math.max(0, (voice - silenceGate) / (responseCeiling - silenceGate)));
|
||||
const easedVoice = gatedVoice * gatedVoice * (3 - 2 * gatedVoice);
|
||||
const visualVoice = Math.pow(easedVoice, 0.42);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 3,
|
||||
width: 42,
|
||||
height: max,
|
||||
}}
|
||||
>
|
||||
{envelope.map((env, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 3,
|
||||
height: base + (max - base) * visualVoice * env,
|
||||
borderRadius: 999,
|
||||
background: 'var(--ol-blue)',
|
||||
opacity: 0.82,
|
||||
transformOrigin: 'center',
|
||||
// 0.08s 在 60Hz audio-level 更新下太快,每次 re-render 都重启 transition,
|
||||
// 视觉上是阶梯式跳变。延长到 0.18s 让多次 update 在曲线内平滑混合,
|
||||
// easeOutExpo-like 缓动让圆点→长条的形变自然顺滑(用户原话"圆形跳成矩形")。
|
||||
transition: 'height 0.18s cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CenterTextProps {
|
||||
os: OS;
|
||||
kind: 'default' | 'processing' | 'error';
|
||||
text: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function CenterText({ os, kind, text, color = 'var(--ol-capsule-center-ink)' }: CenterTextProps) {
|
||||
const metrics = getCapsulePillMetrics(os);
|
||||
const layout = getCapsuleMessageLayout(os, kind);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color,
|
||||
width: '100%',
|
||||
maxWidth: metrics.textWidth,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
lineHeight: layout.allowWrap ? 1.2 : 1,
|
||||
whiteSpace: layout.allowWrap ? 'normal' : 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: layout.lineClamp,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CircleButtonProps {
|
||||
variant: 'cancel' | 'confirm';
|
||||
enabled: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// memo:录音时 level 每帧(~60Hz)变化会重渲 Pill;cancel/confirm 两个 SVG 按钮跟
|
||||
// level 无关,memo + 稳定的 onClick 让它们在录音期间跳过重渲(只剩音量条真正更新)。
|
||||
const CircleButton = memo(function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCancel = variant === 'cancel';
|
||||
return (
|
||||
<button
|
||||
onClick={enabled ? onClick : undefined}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
aria-label={isCancel ? t('common.cancel') : t('settings.shortcuts.confirm')}
|
||||
disabled={!enabled}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
background: isCancel ? 'var(--ol-capsule-btn-bg)' : 'var(--ol-capsule-btn-bg-confirm)',
|
||||
color: 'var(--ol-capsule-btn-ink)',
|
||||
border: '0.8px solid var(--ol-capsule-btn-border)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: enabled ? 'default' : 'not-allowed',
|
||||
opacity: enabled ? 1 : 0.42,
|
||||
visibility: 'visible',
|
||||
flexShrink: 0,
|
||||
padding: 0,
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
|
||||
transition: 'opacity 0.18s var(--ol-motion-soft), background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
{isCancel ? (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11">
|
||||
<path d="M1.5 1.5l8 8M9.5 1.5l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="13" height="13" viewBox="0 0 13 13">
|
||||
<path d="M2 6.5l3.2 3.5L11 3.5" stroke="currentColor" strokeWidth="1.7" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
interface PillProps {
|
||||
interface VoiceOrbStageProps {
|
||||
os: OS;
|
||||
state: CapsuleState;
|
||||
level: number;
|
||||
insertedChars: number;
|
||||
/** 预备态:录音光条渲染成「待命」呼吸形态,不接真实电平。见 CapsulePayload.warming。 */
|
||||
warming?: boolean;
|
||||
/** 预备→就绪的平均耗时(ms),驱动展开动画的预测节奏。见 SiriGL warmupMs。 */
|
||||
warmupMs?: number;
|
||||
message?: string;
|
||||
operating?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function Pill({ os, state, level, insertedChars, message, operating, onCancel, onConfirm }: PillProps) {
|
||||
/**
|
||||
* 纯光效舞台(siri-glsl 完整克隆,无壳无按钮无底):
|
||||
* - recording:彩虹光谱声波横贯舞台,振幅随真实麦克风电平起伏;
|
||||
* - transcribing / polishing:波形从两端向中间收缩汇聚,流体圆点环淡入加速转动;
|
||||
* - done / cancelled:转速回落标准,六点合并成中央一颗圆,由外层 capsule-out 淡出;
|
||||
* - error:冻结光效 + 浮一行发光红字说明原因(唯一保留的文字信息)。
|
||||
* 刻意没有任何垫底/暗晕(用户拍板):白底界面上宁可对比度弱,也不要黑色遮挡。
|
||||
*/
|
||||
function VoiceOrbStage({ os, state, level, warming, warmupMs, message }: VoiceOrbStageProps) {
|
||||
const { t } = useTranslation();
|
||||
const metrics = useMemo(() => getCapsulePillMetrics(os), [os]);
|
||||
const processingLayout = useMemo(() => getCapsuleMessageLayout(os, 'processing'), [os]);
|
||||
const cancelEnabled = state === 'recording' || state === 'transcribing' || state === 'polishing';
|
||||
const confirmEnabled = state === 'recording';
|
||||
|
||||
// "thinking" 扫光速度:进入 transcribing/polishing 的头 2 秒走快速(0.9s/cycle,提示
|
||||
// 「流式刚开始」),之后切回慢速(2.4s)作为稳态。切回 idle / done / 其他 state 也复位
|
||||
// 为 fast,下次进入时从头开始 burst。
|
||||
const [shineFast, setShineFast] = useState(true);
|
||||
// done / cancelled / error 冻结最后形态淡出,不再切换 phase。
|
||||
const lastPhaseRef = useRef<'wave' | 'orb'>('wave');
|
||||
let phase = lastPhaseRef.current;
|
||||
if (state === 'recording') phase = 'wave';
|
||||
else if (state === 'transcribing' || state === 'polishing') phase = 'orb';
|
||||
lastPhaseRef.current = phase;
|
||||
const isOrb = phase === 'orb';
|
||||
|
||||
// 性能:波形淡出彻底结束(.55s delay + .6s duration)后卸载它的绘制循环 ——
|
||||
// 思考期间不再为一块不可见的 canvas 每帧跑 fragment。回到录音态立即重挂
|
||||
//(shader 编译已被驱动缓存,重建近零耗时)。
|
||||
const [waveAlive, setWaveAlive] = useState(true);
|
||||
useEffect(() => {
|
||||
if (state === 'transcribing' || state === 'polishing') {
|
||||
setShineFast(true);
|
||||
const t = setTimeout(() => setShineFast(false), 2000);
|
||||
return () => clearTimeout(t);
|
||||
if (!isOrb) {
|
||||
setWaveAlive(true);
|
||||
return undefined;
|
||||
}
|
||||
setShineFast(true);
|
||||
return undefined;
|
||||
}, [state]);
|
||||
|
||||
let center: JSX.Element;
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
center = <AudioBars level={level} />;
|
||||
break;
|
||||
case 'transcribing':
|
||||
case 'polishing':
|
||||
center = (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
// 左右 4px 内边距 + 外层 gap 已经让 "thinking" ↔ ✗/✓ 视觉间距落在 ~4-5px。
|
||||
padding: '0 4px',
|
||||
width: '100%',
|
||||
maxWidth: metrics.textWidth,
|
||||
minWidth: 0,
|
||||
justifyContent: 'center',
|
||||
// state 进入动画 —— 用户从 recording 切到 polishing 时多一道淡入提示,
|
||||
// 比纯切换 center 内容更容易被感知。
|
||||
animation: 'cap-state-enter 220ms var(--ol-motion-soft) both',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
// v1.3.1-7 用户拍板:黑色底字 + 蓝色扫光(亮黄太显眼,黑底更稳)。
|
||||
// 字号保持 17,字重 700 → 600 稍细一些。
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.3,
|
||||
// line-height: 1 下 g/y/p 等下伸字符会被 clip,给 padding 留 descender 空间。
|
||||
paddingBlock: 1,
|
||||
color: 'var(--ol-ink-2)',
|
||||
backgroundImage:
|
||||
'linear-gradient(100deg, var(--ol-ink) 0%, var(--ol-ink) 35%, var(--ol-blue) 50%, var(--ol-ink) 65%, var(--ol-ink) 100%)',
|
||||
backgroundSize: '220% auto',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
// 进入流式的头 ~2 秒用 0.9s 高速扫光(视觉提示「刚开始」),之后 React 副作用
|
||||
// 切到 2.4s 慢速。duration 变化时浏览器不重启动画,会平滑减速。
|
||||
animation: `cap-shine ${shineFast ? '0.9s' : '2.4s'} linear infinite`,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
lineHeight: processingLayout.allowWrap ? 1.3 : 1.25,
|
||||
whiteSpace: processingLayout.allowWrap ? 'normal' : 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: processingLayout.lineClamp,
|
||||
}}
|
||||
>
|
||||
{t(operating ? 'capsule.using' : 'capsule.thinking')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
case 'done':
|
||||
center = <CenterText os={os} kind="default" text={message || t('capsule.inserted', { count: insertedChars })} />;
|
||||
break;
|
||||
case 'cancelled':
|
||||
center = <CenterText os={os} kind="default" text={t('capsule.cancelled')} />;
|
||||
break;
|
||||
case 'error':
|
||||
center = <CenterText os={os} kind="error" text={message || t('capsule.error')} color="var(--ol-err)" />;
|
||||
break;
|
||||
default:
|
||||
center = <AudioBars level={0} />;
|
||||
}
|
||||
|
||||
const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0;
|
||||
const scale = os === 'win' ? 1 : 1 + ambient * 0.018;
|
||||
const shadowAlpha = 0.20 + ambient * 0.10;
|
||||
const timer = setTimeout(() => setWaveAlive(false), 1300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOrb]);
|
||||
|
||||
return (
|
||||
// 非 Linux 走假毛玻璃;Linux 禁用透明窗口后由 .ol-frost 平台规则退成不透明面。
|
||||
// 不写 backdrop-filter —— webview 模糊不了透明窗口背后的桌面(Tauri 上游限制)。
|
||||
<div
|
||||
className="ol-frost ol-capsule-pill"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
padding: '0 8px',
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
boxSizing: metrics.boxSizing,
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--ol-capsule-pill-border)',
|
||||
boxShadow: `${os === 'win' ? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)})` : `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)})`}, 0 0 0 0.5px rgba(0, 0, 0, 0.24), var(--ol-capsule-pill-inset)`,
|
||||
color: 'var(--ol-capsule-center-ink)',
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
transform: `scale(${scale.toFixed(4)})`,
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 0.08s var(--ol-motion-quick), box-shadow 0.08s var(--ol-motion-quick)',
|
||||
willChange: 'transform, box-shadow',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<CircleButton variant="cancel" enabled={cancelEnabled} onClick={onCancel} />
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{center}
|
||||
</div>
|
||||
<CircleButton variant="confirm" enabled={confirmEnabled} onClick={onConfirm} />
|
||||
{waveAlive && (
|
||||
<SiriGL
|
||||
mode="wave"
|
||||
level={level}
|
||||
resolved={!isOrb}
|
||||
warming={warming}
|
||||
warmupMs={warmupMs}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// 收缩汇聚进行时波形保持可见,收成中央光点后再淡出,与圆点环的淡入交叠。
|
||||
opacity: isOrb ? 0 : 1,
|
||||
transition: isOrb ? 'opacity .6s ease-out .55s' : 'opacity .25s ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isOrb && (
|
||||
<SiriGL
|
||||
mode="orb"
|
||||
// 思考中(LLM 接收)加速转动;插入/取消/出错时回落到标准速度,
|
||||
// 同时六点合并成中央一颗圆,随外层淡出一起消失。
|
||||
speed={state === 'transcribing' || state === 'polishing' ? 1.5 : 1.0}
|
||||
merging={state !== 'transcribing' && state !== 'polishing'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: 170,
|
||||
height: 170,
|
||||
marginLeft: -85,
|
||||
marginTop: -85,
|
||||
animation: 'siri-orb-in .7s ease-out .3s both',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<span style={errorGlowTextStyle}>{message || t('capsule.error')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 与 @keyframes capsule-out 的 0.36s 时长一致——必须同步,否则定时器先于
|
||||
const errorGlowTextStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: 400,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'center',
|
||||
color: '#ff7a70',
|
||||
textShadow: '0 0 14px rgba(255,70,60,0.5), 0 1px 6px rgba(0,0,0,0.6)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
};
|
||||
|
||||
// 与 @keyframes capsule-out 的 0.52s 时长一致——必须同步,否则定时器先于
|
||||
// 动画结束就 unmount → 用户看到半截动画被截断。
|
||||
// v1.3.1-6: 从 240ms 加到 360ms 让用户看清退出动画(240ms 太快感知不到)。
|
||||
const EXIT_ANIM_MS = 360;
|
||||
const EXIT_ANIM_MS = 520;
|
||||
|
||||
// 胶囊「预备→就绪」的平均耗时(ms),驱动入场展开动画的预测节奏(见 SiriGL warmProgress):
|
||||
// 每次录音就绪后按 EMA 更新并存 localStorage 跨会话学习。默认 150ms,clamp [60,600]。
|
||||
const WARMUP_MS_KEY = 'ol-capsule-warmup-ms';
|
||||
const WARMUP_MS_DEFAULT = 150;
|
||||
function readWarmupMs(): number {
|
||||
if (typeof localStorage === 'undefined') return WARMUP_MS_DEFAULT;
|
||||
const raw = Number(localStorage.getItem(WARMUP_MS_KEY));
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.min(600, Math.max(60, raw)) : WARMUP_MS_DEFAULT;
|
||||
}
|
||||
// #470 诊断 v2:模块级一次性门,只在 webview 收到第一个 capsule:state 事件时打 log。
|
||||
let capsuleStateFirstLogged = false;
|
||||
|
||||
// 初始可见 state:Tauri 内运行从 idle 开始(等后端 capsule:state 事件),
|
||||
// 浏览器 dev 模式从 recording 开始以便直接看到胶囊。
|
||||
const INITIAL_VISIBLE_STATE: CapsuleState = isTauri ? 'idle' : 'recording';
|
||||
const CAPSULE_PREVIEW_STATES: CapsuleState[] = [
|
||||
'idle',
|
||||
'recording',
|
||||
'transcribing',
|
||||
'polishing',
|
||||
'done',
|
||||
'cancelled',
|
||||
'error',
|
||||
];
|
||||
|
||||
export function Capsule() {
|
||||
function getPreviewCapsulePayload() {
|
||||
if (isTauri || typeof window === 'undefined') {
|
||||
return {
|
||||
state: 'idle' as CapsuleState,
|
||||
level: 0,
|
||||
message: undefined,
|
||||
translation: false,
|
||||
warming: false,
|
||||
};
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const stateParam = params.get('state');
|
||||
const previewState = CAPSULE_PREVIEW_STATES.includes(stateParam as CapsuleState)
|
||||
? (stateParam as CapsuleState)
|
||||
: 'recording';
|
||||
const previewLevel = Number(params.get('level') ?? 0.6);
|
||||
return {
|
||||
state: previewState,
|
||||
level: Number.isFinite(previewLevel) ? Math.min(1, Math.max(0, previewLevel)) : 0.6,
|
||||
message: params.get('message') ?? undefined,
|
||||
translation: params.get('translation') === '1',
|
||||
warming: params.get('warming') === '1',
|
||||
};
|
||||
}
|
||||
|
||||
interface CapsuleProps {
|
||||
os?: OS | null;
|
||||
}
|
||||
|
||||
export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const os = detectOS();
|
||||
const os = forcedOs ?? detectOS();
|
||||
const preview = useMemo(() => getPreviewCapsulePayload(), []);
|
||||
const metrics = getCapsulePillMetrics(os);
|
||||
const [state, setState] = useState<CapsuleState>(INITIAL_VISIBLE_STATE);
|
||||
const [level, setLevel] = useState<number>(isTauri ? 0 : 0.6);
|
||||
const [insertedChars, setInsertedChars] = useState<number>(0);
|
||||
const [message, setMessage] = useState<string | undefined>();
|
||||
const [translation, setTranslation] = useState<boolean>(false);
|
||||
const [operating, setOperating] = useState<boolean>(false);
|
||||
const [state, setState] = useState<CapsuleState>(preview.state);
|
||||
const [level, setLevel] = useState<number>(preview.level);
|
||||
const [message, setMessage] = useState<string | undefined>(preview.message);
|
||||
const [translation, setTranslation] = useState<boolean>(preview.translation);
|
||||
// 预备态:麦克风尚未吐第一帧 PCM。true 时录音光条走「待命」呼吸形态(见 SiriGL warming)。
|
||||
const [warming, setWarming] = useState<boolean>(preview.warming);
|
||||
// 预备→就绪耗时的移动平均,驱动光条展开动画的预测节奏(见 SiriGL warmProgress)。
|
||||
const [warmupMs, setWarmupMs] = useState<number>(() => readWarmupMs());
|
||||
const warmStartRef = useRef<number | null>(null);
|
||||
// `leaving` 与 `lastVisibleState` 协同实现「退出动画」:
|
||||
// - 当 state 从非 idle 变成 idle 时,不立即卸载,而是把 leaving 置为 true 并保留
|
||||
// 最后一帧的可见 state(lastVisibleState),让胶囊用 capsule-out 动画收缩淡出。
|
||||
// 最后一帧的可见 state(lastVisibleState),让语音 orb 用 capsule-out 动画收缩淡出。
|
||||
// - 动画结束(EXIT_ANIM_MS)后再把 leaving 置回 false,组件回到「真正未挂载」分支。
|
||||
// - 若期间 state 又切回非 idle(例如用户连按热键),立刻中止 leaving 并恢复显示。
|
||||
const [leaving, setLeaving] = useState<boolean>(false);
|
||||
const [lastVisibleState, setLastVisibleState] = useState<CapsuleState>(INITIAL_VISIBLE_STATE);
|
||||
// Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。
|
||||
const [lastVisibleState, setLastVisibleState] = useState<CapsuleState>(preview.state);
|
||||
// 前端 host 与原生窗口保持同一份透明语音 orb 舞台尺寸。
|
||||
const hostMetrics = getCapsuleHostMetrics(os, translation);
|
||||
const badgeBottom = Math.round(metrics.height * 0.73);
|
||||
|
||||
// 空闲预热 shader 编译缓存:首次按下热键时光条不用现场编译(响应延迟反馈)。
|
||||
useEffect(() => {
|
||||
const idle = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
|
||||
.requestIdleCallback;
|
||||
if (idle) {
|
||||
idle(() => warmUpSiriShaders());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => warmUpSiriShaders(), 1200);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
@@ -365,9 +263,8 @@ export function Capsule() {
|
||||
setState(p.state);
|
||||
setLevel(p.level ?? 0);
|
||||
setMessage(p.message ?? undefined);
|
||||
if (p.insertedChars != null) setInsertedChars(p.insertedChars);
|
||||
setTranslation(p.translation === true);
|
||||
setOperating(p.operating === true);
|
||||
setWarming(p.warming === true);
|
||||
});
|
||||
if (cancelled) handle();
|
||||
else unlisten = handle;
|
||||
@@ -404,20 +301,30 @@ export function Capsule() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
void invokeOrMock<void>('cancel_dictation', undefined, () => undefined);
|
||||
}, []);
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
void invokeOrMock<void>('stop_dictation', undefined, () => undefined);
|
||||
}, []);
|
||||
// 学习「预备态持续多久」= 麦克风就绪耗时,EMA 更新 warmupMs(预测展开的基准时长)。
|
||||
useEffect(() => {
|
||||
if (warming) {
|
||||
warmStartRef.current = performance.now();
|
||||
return;
|
||||
}
|
||||
if (warmStartRef.current == null) return;
|
||||
const loadMs = performance.now() - warmStartRef.current;
|
||||
warmStartRef.current = null;
|
||||
// 过滤异常样本:<20ms 多半不是真入场;>3s 多半首次 TCC / 卡顿,不代表常态。
|
||||
if (loadMs < 20 || loadMs > 3000) return;
|
||||
setWarmupMs(prev => {
|
||||
const next = Math.min(600, Math.max(60, prev * 0.7 + loadMs * 0.3));
|
||||
try { localStorage.setItem(WARMUP_MS_KEY, String(Math.round(next))); } catch { /* ignore */ }
|
||||
return next;
|
||||
});
|
||||
}, [warming]);
|
||||
|
||||
// 真正卸载:state 已是 idle,且不在离场动画中。
|
||||
if (state === 'idle' && !leaving) {
|
||||
return <div style={{ width: 0, height: 0 }} />;
|
||||
}
|
||||
|
||||
// 离场时用 lastVisibleState 渲染最后一帧内容,避免把 idle 当作 fallback 走到 AudioBars(0)。
|
||||
// 离场时用 lastVisibleState 渲染最后一帧内容,避免把 idle 当作无波形状态。
|
||||
const renderedState: CapsuleState = state === 'idle' ? lastVisibleState : state;
|
||||
|
||||
return (
|
||||
@@ -437,18 +344,11 @@ export function Capsule() {
|
||||
: 0,
|
||||
paddingBottom: os === 'win' ? hostMetrics.bottomInset : 0,
|
||||
background: 'transparent',
|
||||
// 入场:中央 scaleX 由 0.18 长到 1(视觉上像从中心向两端展开)+ 淡入。
|
||||
// 离场:scaleX 由 1 收缩回 0.18 + 向下偏移 8px + 淡出。
|
||||
// 三平台一致 —— 旧版 Windows 走 animation:'none' 的分支已删除。
|
||||
// transformOrigin 默认就是 50% 50%,所以 scaleX 天然以中央为锚点。
|
||||
animation: leaving
|
||||
// v1.3.1-6 调整:
|
||||
// - 入场 .26s → .38s,cubic-bezier 加强 spring overshoot(更曲线感)
|
||||
// - 出场 .24s → .36s(前面 EXIT_ANIM_MS 也同步到 360),曲线改成 ease-in-out 平滑
|
||||
// 收缩 + 下移 + 淡出三段同步进行
|
||||
? 'capsule-out .36s cubic-bezier(.55,.06,.68,.19) forwards'
|
||||
// 入场改成弹性"弹出":整体 scale 从 .78 弹到 1,back-out 曲线带回弹 overshoot。
|
||||
: 'capsule-in .46s cubic-bezier(.34,1.56,.64,1) both',
|
||||
? 'capsule-out .52s cubic-bezier(.55,.06,.68,.19) forwards'
|
||||
// .68s 的入场被反馈「按下之后有延迟」:压到 .38s,曲线保持轻微弹性,
|
||||
// 光条几乎跟手出现。
|
||||
: 'capsule-in .38s cubic-bezier(.3,1.2,.4,1) both',
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
@@ -461,11 +361,7 @@ export function Capsule() {
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
// macOS / Linux:胶囊窗口 220×110、pill 居中,badge 锚到 pill 中线上方 21+8。
|
||||
// Windows:host 比 pill 多出左右 12px / 底部 12px 的阴影空间,pill 仍保持居中。
|
||||
bottom: os === 'win'
|
||||
? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`
|
||||
: 'calc(50% + 21px + 8px)',
|
||||
bottom: `${badgeBottom}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
@@ -475,19 +371,14 @@ export function Capsule() {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 999,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ol-blue)',
|
||||
background: 'var(--ol-capsule-badge-bg)',
|
||||
// issue #470:去掉无效的 backdrop-filter —— webview 模糊不了透明窗口背后的桌面
|
||||
// (Tauri 上游限制,同本文件上方 pill 注释),纯空耗合成,删除零视觉变化。
|
||||
border: '0.5px solid var(--ol-capsule-badge-border)',
|
||||
boxShadow: '0 4px 12px -4px rgba(37, 99, 235, 0.25), 0 0 0 0.5px rgba(0,0,0,0.04)',
|
||||
// 纯光效语言:无壳发光小字,与波形同一气质(浮空 + 冷蓝光晕)。
|
||||
color: 'rgba(190, 212, 255, 0.95)',
|
||||
textShadow: '0 0 14px rgba(90, 140, 255, 0.85), 0 1px 6px rgba(0, 0, 0, 0.45)',
|
||||
letterSpacing: '0.02em',
|
||||
whiteSpace: 'nowrap',
|
||||
// 隐藏:从 pill 中线偏下出发;显示:归位到 wrapper(pill 上方 25px)
|
||||
// 隐藏:从光条附近偏下出发;显示:归位到光条上方。
|
||||
opacity: translation ? 1 : 0,
|
||||
transform: translation ? 'translateY(0) scale(1)' : 'translateY(40px) scale(.88)',
|
||||
transformOrigin: 'center bottom',
|
||||
@@ -495,19 +386,25 @@ export function Capsule() {
|
||||
willChange: 'opacity, transform',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--ol-blue)' }} />
|
||||
<span
|
||||
style={{
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(150, 185, 255, 0.95)',
|
||||
boxShadow: '0 0 8px rgba(90, 140, 255, 0.9)',
|
||||
}}
|
||||
/>
|
||||
{t('capsule.translating')}
|
||||
</div>
|
||||
</div>
|
||||
<Pill
|
||||
<VoiceOrbStage
|
||||
os={os}
|
||||
state={renderedState}
|
||||
level={leaving ? 0 : level}
|
||||
insertedChars={insertedChars}
|
||||
warming={!leaving && warming}
|
||||
warmupMs={warmupMs}
|
||||
message={message}
|
||||
operating={operating}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
//
|
||||
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
|
||||
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useState, type ComponentType, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from './Icon';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import { WindowChrome, detectOS, type OS } from './WindowChrome';
|
||||
import { AudioCueListener } from "./AudioCue";
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
@@ -14,9 +15,10 @@ import { Overview } from '../pages/Overview';
|
||||
import { History } from '../pages/History';
|
||||
import { Vocab } from '../pages/Vocab';
|
||||
import { Style } from '../pages/Style';
|
||||
import { Marketplace } from '../pages/Marketplace';
|
||||
import { Translation } from '../pages/Translation';
|
||||
import { SelectionAsk } from '../pages/SelectionAsk';
|
||||
// 风格市场不再作为独立 nav tab —— 已整合为 Style 页面内 modal(入口在「风格包」标题右侧)。
|
||||
// 风格市场(Marketplace)现在是侧栏「风格」展开组下的独立页面(不再是 Style 页面内 modal)。
|
||||
// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Advanced 中
|
||||
// 通过 <LocalAsr embedded /> 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。
|
||||
import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion';
|
||||
@@ -38,22 +40,44 @@ import { useAppState, type AppTab } from '../state/useAppState';
|
||||
|
||||
const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk'];
|
||||
|
||||
interface NavItem {
|
||||
id: AppTab;
|
||||
name: string;
|
||||
icon: string;
|
||||
cmp: ComponentType;
|
||||
}
|
||||
/** macOS 上侧栏顶部需让开原生红绿灯的高度(红绿灯竖直落在 ~6–22px)。 */
|
||||
const MAC_TRAFFIC_LIGHT_CLEARANCE = 30;
|
||||
const SIDEBAR_WIDTH = 188;
|
||||
|
||||
const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
|
||||
{ id: 'overview', icon: 'overview', cmp: Overview },
|
||||
{ id: 'history', icon: 'history', cmp: History },
|
||||
{ id: 'vocab', icon: 'vocab', cmp: Vocab },
|
||||
{ id: 'style', icon: 'style', cmp: Style },
|
||||
{ id: 'translation', icon: 'translate', cmp: Translation },
|
||||
{ id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk },
|
||||
/** tab → 页面组件映射(渲染主内容用;与侧栏树解耦,含未直接列在树上的页)。 */
|
||||
const PAGE_CMP: Record<Exclude<AppTab, 'localAsr'>, ComponentType> = {
|
||||
overview: Overview,
|
||||
history: History,
|
||||
vocab: Vocab,
|
||||
style: Style,
|
||||
marketplace: Marketplace,
|
||||
translation: Translation,
|
||||
selectionAsk: SelectionAsk,
|
||||
};
|
||||
|
||||
/** 侧栏导航树:扁平项 + 可展开分组(用户拍板的结构)。
|
||||
* - 概览 / 历史 / 词汇:扁平项,位置不变。
|
||||
* - 风格:展开组 → 润色模式(=style 页) + 风格市场(=marketplace 页,原为 Style 内 modal)。
|
||||
* - 工具:展开组 → 划词追问 + 翻译(原本两个扁平项合并成一个可展开分组)。 */
|
||||
type NavNode =
|
||||
| { kind: 'item'; id: AppTab; icon: string }
|
||||
| { kind: 'group'; key: string; icon: string; children: Array<{ id: AppTab }> };
|
||||
|
||||
const NAV_TREE: NavNode[] = [
|
||||
{ kind: 'item', id: 'overview', icon: 'overview' },
|
||||
{ kind: 'item', id: 'history', icon: 'history' },
|
||||
{ kind: 'item', id: 'vocab', icon: 'vocab' },
|
||||
{ kind: 'group', key: 'style', icon: 'style', children: [{ id: 'style' }, { id: 'marketplace' }] },
|
||||
{ kind: 'group', key: 'tools', icon: 'selectionAsk', children: [{ id: 'selectionAsk' }, { id: 'translation' }] },
|
||||
];
|
||||
|
||||
/** 分组标题的 i18n key(nav.group.<key>)。子项标题:style 组用 nav.polishMode/nav.marketplace,
|
||||
* 其余子项复用 nav.<id>。 */
|
||||
function subItemLabelKey(id: AppTab): string {
|
||||
if (id === 'style') return 'nav.polishMode';
|
||||
return `nav.${id}`;
|
||||
}
|
||||
|
||||
interface FloatingShellProps {
|
||||
os?: OS;
|
||||
initialTab?: AppTab;
|
||||
@@ -97,30 +121,25 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
applyFontScale(readFontScale());
|
||||
}, []);
|
||||
|
||||
const NAV = useMemo<NavItem[]>(
|
||||
() => NAV_BASE.map(b => ({ ...b, name: t(`nav.${b.id}`) })),
|
||||
[t],
|
||||
);
|
||||
const Page = (NAV.find((n) => n.id === displayTab) ?? NAV[0]).cmp;
|
||||
const Page = PAGE_CMP[displayTab as Exclude<AppTab, 'localAsr'>] ?? Overview;
|
||||
|
||||
// sidebar nav 滑动指示器:测量当前 active button 的 offsetTop / height,
|
||||
// 用一个 absolute pill 平滑滑过去,而不是每个按钮各自瞬切背景色。
|
||||
const navItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (settingsOpen) {
|
||||
setPillRect(null);
|
||||
return;
|
||||
// 分组展开态:默认展开「当前所在页所属的分组」。用户点分组标题手动切换。
|
||||
const groupOfTab = (tab: AppTab): string | null => {
|
||||
for (const node of NAV_TREE) {
|
||||
if (node.kind === 'group' && node.children.some(c => c.id === tab)) return node.key;
|
||||
}
|
||||
const idx = NAV.findIndex(n => n.id === currentTab);
|
||||
if (idx < 0) {
|
||||
setPillRect(null);
|
||||
return;
|
||||
}
|
||||
const el = navItemRefs.current[idx];
|
||||
if (!el) return;
|
||||
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
|
||||
}, [currentTab, settingsOpen, NAV]);
|
||||
return null;
|
||||
};
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
|
||||
const active = groupOfTab(currentTab);
|
||||
return active ? { [active]: true } : {};
|
||||
});
|
||||
// 切到某分组内的页时自动展开该组(例如从胶囊直接跳到 marketplace)。
|
||||
useEffect(() => {
|
||||
const active = groupOfTab(currentTab);
|
||||
if (active) setOpenGroups(prev => (prev[active] ? prev : { ...prev, [active]: true }));
|
||||
}, [currentTab]);
|
||||
const toggleGroup = (key: string) => setOpenGroups(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -189,12 +208,14 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
|
||||
const mobileTitle = settingsOpen
|
||||
? t('shell.footer.settings')
|
||||
: (NAV.find(n => n.id === currentTab)?.name ?? t('nav.overview'));
|
||||
: t(subItemLabelKey(currentTab));
|
||||
const moreTabActive = MORE_TAB_IDS.includes(currentTab);
|
||||
const useOpaqueMain = mobile || os === 'linux';
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: mobile ? 0 : os === 'mac' ? 28 : 0, background: 'var(--ol-app-shell-bg)' }}>
|
||||
// 不再为 macOS 红绿灯预留顶部 28px 空条(用户反馈「块上方多一条丑横条」):
|
||||
// 侧栏与内容块都顶到窗口最上沿,原生红绿灯直接浮在侧栏左上角的块面上。
|
||||
// 侧栏内 brand 行在 mac 上加 topClearance 让「OpenLess」避开红绿灯。
|
||||
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: 0, background: 'var(--ol-app-shell-bg)' }}>
|
||||
|
||||
{mobile && (
|
||||
<MobileTopBar
|
||||
@@ -215,16 +236,19 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
zIndex: 1,
|
||||
}}>
|
||||
|
||||
{/* Sidebar — desktop / wide only */}
|
||||
{/* Sidebar — desktop / wide only。 */}
|
||||
{!mobile && (
|
||||
<aside
|
||||
className="ol-sidebar-surface"
|
||||
style={{
|
||||
width: 188,
|
||||
width: SIDEBAR_WIDTH,
|
||||
height: '100%',
|
||||
flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
padding: '10px 10px 12px',
|
||||
background: 'var(--ol-sidebar-bg)',
|
||||
borderRight: '0.5px solid var(--ol-line)',
|
||||
// mac:顶部空出红绿灯高度,让 brand/nav 落在红绿灯下方。
|
||||
padding: os === 'mac' ? `${MAC_TRAFFIC_LIGHT_CLEARANCE}px 10px 12px` : '10px 10px 12px',
|
||||
}}>
|
||||
|
||||
{/* brand */}
|
||||
@@ -232,57 +256,83 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
<img
|
||||
src="AppIcon.png"
|
||||
alt="OpenLess"
|
||||
style={{ width: 22, height: 22, borderRadius: 5, boxShadow: '0 1px 2px rgba(0,0,0,.1), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
|
||||
style={{ width: 22, height: 22, borderRadius: 5, flexShrink: 0, boxShadow: '0 1px 2px rgba(0,0,0,.1), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
|
||||
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, letterSpacing: '-0.01em', color: 'var(--ol-ink)' }}>OpenLess</div>
|
||||
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, letterSpacing: '-0.01em', color: 'var(--ol-ink)', whiteSpace: 'nowrap' }}>OpenLess</span>
|
||||
</div>
|
||||
|
||||
{/* nav — 滑动指示器:active pill 是 absolute 元素,currentTab 改变时 top/height
|
||||
过渡到目标按钮的位置,而非各按钮自己瞬切背景色。hover 灰底通过 .ol-nav-btn 的
|
||||
CSS :hover 规则实现,仅对非 active 项生效。 */}
|
||||
<nav style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{pillRect && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: pillRect.top,
|
||||
height: pillRect.height,
|
||||
background: 'var(--ol-sidebar-pill-bg)',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--ol-segmented-active-shadow)',
|
||||
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{NAV.map((n, i) => {
|
||||
const active = !settingsOpen && currentTab === n.id;
|
||||
{/* nav — 扁平项 + 可展开分组(用户拍板结构)。扁平项:概览/历史/词汇。
|
||||
分组:风格(润色模式+风格市场) / 工具(划词追问+翻译)。active 项由
|
||||
.ol-nav-btn-active class 给 surface-2 静态圆角底;分组标题点击展开/收起。 */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{NAV_TREE.map((node) => {
|
||||
if (node.kind === 'item') {
|
||||
const active = !settingsOpen && currentTab === node.id;
|
||||
return (
|
||||
<Tooltip key={node.id} content={t(`shell.navHint.${node.id}`)} placement="right">
|
||||
<button
|
||||
onClick={() => setCurrentTab(node.id)}
|
||||
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={navBtnStyle}
|
||||
>
|
||||
<Icon name={node.icon} size={14} />
|
||||
<span style={{ flex: 1 }}>{t(`nav.${node.id}`)}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const expanded = !!openGroups[node.key];
|
||||
const groupActive = !settingsOpen && node.children.some(c => c.id === currentTab);
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
ref={el => { navItemRefs.current[i] = el; }}
|
||||
onClick={() => setCurrentTab(n.id)}
|
||||
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8, border: 0,
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
cursor: 'default',
|
||||
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
|
||||
textAlign: 'left',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
|
||||
<Icon name={n.icon} size={14} />
|
||||
<span style={{ flex: 1 }}>{n.name}</span>
|
||||
</button>
|
||||
<div key={node.key}>
|
||||
{/* 分组标题:点击只展开/收起,不导航(用户:点风格弹出下拉选项)。 */}
|
||||
<button
|
||||
onClick={() => toggleGroup(node.key)}
|
||||
className={groupActive && !expanded ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
aria-expanded={expanded}
|
||||
style={navBtnStyle}
|
||||
>
|
||||
<Icon name={node.icon} size={14} />
|
||||
<span style={{ flex: 1 }}>{t(`nav.group.${node.key}`)}</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden
|
||||
style={{
|
||||
color: 'var(--ol-ink-4)', flexShrink: 0,
|
||||
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.20s var(--ol-motion-spring)',
|
||||
}}
|
||||
>
|
||||
<path d="M6 3.5 10.5 8 6 12.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* 子项:grid-rows 0fr↔1fr 过渡实现无高度硬编码的展开动画。 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: expanded ? '1fr' : '0fr',
|
||||
transition: 'grid-template-rows 0.24s var(--ol-motion-spring)',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, padding: '1px 0 2px' }}>
|
||||
{node.children.map(child => {
|
||||
const active = !settingsOpen && currentTab === child.id;
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => setCurrentTab(child.id)}
|
||||
className={active ? 'ol-nav-btn ol-nav-subitem ol-nav-btn-active' : 'ol-nav-btn ol-nav-subitem'}
|
||||
tabIndex={expanded ? 0 : -1}
|
||||
style={{ ...navBtnStyle, paddingLeft: 30 }}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{t(subItemLabelKey(child.id))}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -323,42 +373,41 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
<span>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => openSettings()}
|
||||
className={settingsOpen ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8, border: 0,
|
||||
background: settingsOpen ? 'var(--ol-sidebar-settings-active-bg)' : 'transparent',
|
||||
boxShadow: settingsOpen ? 'var(--ol-segmented-active-shadow)' : 'none',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
cursor: 'default',
|
||||
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Icon name="settings" size={14} />
|
||||
<span style={{ flex: 1 }}>{t('shell.footer.settings')}</span>
|
||||
</button>
|
||||
<Tooltip content={t('shell.navHint.settings')} placement="right">
|
||||
<button
|
||||
onClick={() => openSettings()}
|
||||
className={settingsOpen ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8, border: 0,
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
cursor: 'default',
|
||||
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Icon name="settings" size={14} />
|
||||
<span style={{ flex: 1 }}>{t('shell.footer.settings')}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main content — Linux 禁用透明窗口后使用不透明面;mobile 全宽无玻璃层。 */}
|
||||
<div style={{ flex: 1, minWidth: 0, padding: mobile ? 0 : '4px 8px 8px 0', display: 'flex' }}>
|
||||
{/* Main content — 平铺实底块(用户反馈:右侧要一整块、不要圆角、别浮成卡)。
|
||||
surface 实底,无外边距无圆角无阴影,直接顶到窗口边缘,与侧栏之间仅靠
|
||||
侧栏的 border-right 分隔(即「侧栏那套逻辑」)。mac 圆角由原生窗口裁切。 */}
|
||||
<div style={{ flex: 1, minWidth: 0, padding: 0, display: 'flex' }}>
|
||||
<main
|
||||
className="ol-console-main ol-panel-surface"
|
||||
className="ol-console-main"
|
||||
style={{
|
||||
flex: 1, minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
background: useOpaqueMain ? 'var(--ol-panel-bg)' : 'var(--ol-panel-glass-bg)',
|
||||
backdropFilter: useOpaqueMain ? 'none' : 'blur(18px) saturate(170%)',
|
||||
WebkitBackdropFilter: useOpaqueMain ? 'none' : 'blur(18px) saturate(170%)',
|
||||
borderRadius: 'var(--ol-r-lg)',
|
||||
...(mobile ? { borderRadius: 0 } : {}),
|
||||
border: mobile ? 'none' : '0.5px solid var(--ol-panel-border)',
|
||||
boxShadow: mobile ? 'none' : 'var(--ol-panel-shadow)',
|
||||
background: 'var(--ol-surface)',
|
||||
borderRadius: 0,
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
@@ -379,7 +428,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
// 正常尺寸下内容刚好占满 → 浏览器自动不显示 scrollbar;
|
||||
// 真挤不下了才 fallback 出细滚动条。
|
||||
// - 其他 tab 同样走细滚动条。
|
||||
className="ol-thinscroll"
|
||||
className="ol-thinscroll ol-scroll-fade"
|
||||
style={{
|
||||
flex: 1, minHeight: 0,
|
||||
overflow: 'auto',
|
||||
@@ -396,7 +445,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
: (tabPhase === 'exiting'
|
||||
? 'ol-page-fadeout 0.18s var(--ol-motion-soft) forwards'
|
||||
: 'ol-page-slide 0.34s var(--ol-motion-spring) both'),
|
||||
willChange: mobile ? 'opacity' : 'opacity, transform, filter',
|
||||
willChange: mobile ? 'opacity' : 'opacity, transform',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
@@ -459,12 +508,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
|
||||
{/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */}
|
||||
<style>{`
|
||||
/* nav 三段视觉层次:
|
||||
/* nav 三段视觉层次(扁平静态,无 pill 滑块):
|
||||
基础态 → ink-3(中灰文字 + 透明底)
|
||||
hover → ink(深色文字 + 浅灰底) ← 让"翻译"等字词在悬停时高亮,跟基础/选中都拉开差距
|
||||
选中 → ink(深色文字 + 白色 pill 底,由 absolute pill 提供)
|
||||
inline color/fontWeight 留给 active 项写最高优先级;非 active 走 class,
|
||||
这样 :hover 能正确覆盖(CSS 不能盖 inline style)。 */
|
||||
hover → ink(深色文字 + surface-2 浅灰底) ← 让"翻译"等字词在悬停时高亮
|
||||
选中 → ink(深色文字加粗 + surface-2 静态圆角底)
|
||||
全走 class:sidebar 按钮不写 inline background,:hover / active 底色才能生效
|
||||
(CSS 不能盖 inline style);其他 .ol-nav-btn 使用方仍带 inline background,
|
||||
这里的 active 底色对它们不生效,维持各自原样。 */
|
||||
.ol-nav-btn {
|
||||
color: var(--ol-ink-3);
|
||||
font-weight: 500;
|
||||
@@ -472,18 +522,21 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
.ol-nav-btn.ol-nav-btn-active {
|
||||
color: var(--ol-ink);
|
||||
font-weight: 600;
|
||||
background: var(--ol-surface-2);
|
||||
}
|
||||
.ol-nav-btn:not(.ol-nav-btn-active):hover {
|
||||
background: var(--ol-nav-hover-bg);
|
||||
background: var(--ol-surface-2);
|
||||
color: var(--ol-ink);
|
||||
}
|
||||
/* 只动 opacity/transform(合成器友好):filter:blur 每帧全页重算,
|
||||
是 tab 切换「卡」的元凶,已移除。 */
|
||||
@keyframes ol-page-slide {
|
||||
from { opacity: 0; transform: translate3d(10px, 0, 0) scale(.996); filter: blur(6px); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); filter: blur(0); }
|
||||
from { opacity: 0; transform: translate3d(10px, 0, 0) scale(.996); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
|
||||
}
|
||||
@keyframes ol-page-fadeout {
|
||||
from { opacity: 1; filter: blur(0); }
|
||||
to { opacity: 0; filter: blur(8px); }
|
||||
from { opacity: 1; transform: translate3d(0, 0, 0); }
|
||||
to { opacity: 0; transform: translate3d(-6px, 0, 0); }
|
||||
}
|
||||
@keyframes ol-page-fade-mobile {
|
||||
from { opacity: 0; }
|
||||
@@ -667,6 +720,18 @@ const mobileNavBtnStyle: CSSProperties = {
|
||||
cursor: 'default',
|
||||
};
|
||||
|
||||
/** 侧栏导航按钮基础样式(扁平项 / 分组标题 / 子项共用;子项额外覆盖 paddingLeft)。 */
|
||||
const navBtnStyle: CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8, border: 0,
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
cursor: 'default',
|
||||
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; onOpenSettings: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Heatmap.tsx — 年度活动热力图(8starlabs Heatmap 规格的纯 React 版,用户拍板)。
|
||||
//
|
||||
// GitHub 贡献图式布局:列 = 周(周日起),行 = 星期;顶部月份标签,左侧 Mon/Wed/Fri。
|
||||
// 颜色走 interpolate 模式(minColor→maxColor 按值插值,支持 linear/sqrt/log),
|
||||
// 零值格子用主题的 surface-2。提示走原生 title(date + value 的显示函数可定制)。
|
||||
// 取代 PR #716 的自绘实现:数据源与历史保留策略解耦(persistence/activity.rs)。
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
|
||||
export interface HeatmapValue {
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface HeatmapProps {
|
||||
data: HeatmapValue[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
cellSize?: number;
|
||||
gap?: number;
|
||||
/** 值→颜色的插值曲线:log 适合量级差异大的数据。 */
|
||||
interpolation?: 'linear' | 'sqrt' | 'log';
|
||||
/** 最小非零值的颜色(hex)。 */
|
||||
minColor?: string;
|
||||
/** 最大值的颜色(hex)。 */
|
||||
maxColor?: string;
|
||||
/** 星期标签:MWF = 只标 Mon/Wed/Fri。 */
|
||||
daysOfTheWeek?: 'all' | 'MWF' | 'none';
|
||||
dateDisplay?: (date: Date) => string;
|
||||
valueDisplay?: (value: number) => string;
|
||||
monthLabels?: string[];
|
||||
dayLabels?: string[];
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const DEFAULT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const DEFAULT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function isoOf(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const value = hex.replace('#', '');
|
||||
const n = parseInt(value.length === 3 ? value.split('').map(c => c + c).join('') : value, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function mixHex(a: string, b: string, t: number): string {
|
||||
const ca = hexToRgb(a);
|
||||
const cb = hexToRgb(b);
|
||||
const mixed = ca.map((v, i) => Math.round(v + (cb[i] - v) * t));
|
||||
return `rgb(${mixed[0]}, ${mixed[1]}, ${mixed[2]})`;
|
||||
}
|
||||
|
||||
export function Heatmap({
|
||||
data,
|
||||
startDate,
|
||||
endDate,
|
||||
cellSize = 11,
|
||||
gap = 3,
|
||||
interpolation = 'sqrt',
|
||||
minColor = '#bfdbfe',
|
||||
maxColor = '#1d4ed8',
|
||||
daysOfTheWeek = 'MWF',
|
||||
dateDisplay = (date) => date.toDateString(),
|
||||
valueDisplay = (value) => `${value}`,
|
||||
monthLabels = DEFAULT_MONTHS,
|
||||
dayLabels = DEFAULT_DAYS,
|
||||
style,
|
||||
}: HeatmapProps) {
|
||||
const { weeks, max } = useMemo(() => {
|
||||
const counts = new Map(data.map(d => [d.date, d.value]));
|
||||
// 列从 startDate 所在周的周日开始,铺满到 endDate。
|
||||
const cursor = new Date(startDate);
|
||||
cursor.setDate(cursor.getDate() - cursor.getDay());
|
||||
const startIso = isoOf(startDate);
|
||||
const endIso = isoOf(endDate);
|
||||
const columns: { days: { iso: string; date: Date; value: number | null }[]; monthStart: number | null }[] = [];
|
||||
let maxValue = 0;
|
||||
let lastMonth = -1;
|
||||
while (cursor <= endDate) {
|
||||
const days: { iso: string; date: Date; value: number | null }[] = [];
|
||||
let monthStart: number | null = null;
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const date = new Date(cursor);
|
||||
const iso = isoOf(date);
|
||||
const inRange = iso >= startIso && iso <= endIso;
|
||||
const value = inRange ? counts.get(iso) ?? 0 : null;
|
||||
if (value != null && value > maxValue) maxValue = value;
|
||||
// 月份标签挂在「该月 1 日所在列」上。
|
||||
if (inRange && date.getDate() === 1 && date.getMonth() !== lastMonth) {
|
||||
monthStart = date.getMonth();
|
||||
lastMonth = date.getMonth();
|
||||
}
|
||||
days.push({ iso, date, value });
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
columns.push({ days, monthStart });
|
||||
}
|
||||
return { weeks: columns, max: maxValue };
|
||||
}, [data, startDate, endDate]);
|
||||
|
||||
const colorOf = (value: number): string => {
|
||||
if (max <= 0 || value <= 0) return 'var(--ol-surface-2)';
|
||||
const ratio = value / max;
|
||||
const t =
|
||||
interpolation === 'log'
|
||||
? Math.log1p(value) / Math.log1p(max)
|
||||
: interpolation === 'sqrt'
|
||||
? Math.sqrt(ratio)
|
||||
: ratio;
|
||||
return mixHex(minColor, maxColor, Math.min(1, t));
|
||||
};
|
||||
|
||||
const showDayLabel = (index: number): boolean => {
|
||||
if (daysOfTheWeek === 'none') return false;
|
||||
if (daysOfTheWeek === 'all') return true;
|
||||
return index === 1 || index === 3 || index === 5; // Mon / Wed / Fri
|
||||
};
|
||||
|
||||
// 容器自适应(用户拍板「按比例自适应缩放」+「左右要贴边撑满」):格子按容器宽度
|
||||
// 均分周列,让 53 列正好铺满整块卡片、右缘不留空隙。cellSize 只当窄容器下的期望值,
|
||||
// 不再作硬上限——上限会在宽容器上留出右侧空白(用户反馈的「左右没有贴边」)。
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [fitCell, setFitCell] = useState<number | null>(null);
|
||||
const weekCount = weeks.length;
|
||||
useEffect(() => {
|
||||
const el = gridRef.current;
|
||||
if (!el || weekCount === 0) return undefined;
|
||||
const measure = () => {
|
||||
// 直接测格子区自身宽度(星期标签列在 flex 里各占各的,无需估算),
|
||||
// cell 取精确小数 —— floor 会在 53 列上累积出几十像素的右侧空隙。
|
||||
const usable = el.clientWidth;
|
||||
if (usable <= 0) return;
|
||||
const per = usable / weekCount;
|
||||
const g = per >= 13 ? 3 : 2;
|
||||
// step = cell + gap = per ⇒ weekCount × step = usable,格子精确铺满右缘。
|
||||
// 只留 5px 下限防窄容器过挤,不设上限——宽容器时格子随之放大仍贴边。
|
||||
setFitCell(Math.max(5, per - g));
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [weekCount, cellSize, daysOfTheWeek]);
|
||||
|
||||
const cell = fitCell ?? cellSize;
|
||||
const cellGap = cell >= 11 ? Math.min(gap, 3) : 2;
|
||||
const step = cell + cellGap;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, ...style }} aria-hidden={false}>
|
||||
{daysOfTheWeek !== 'none' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: cellGap, paddingTop: 16 }}>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
height: cell,
|
||||
lineHeight: `${cell}px`,
|
||||
fontSize: cell < 9 ? 8 : 9,
|
||||
color: 'var(--ol-ink-4)',
|
||||
visibility: showDayLabel(i) ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{dayLabels[i]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={gridRef} style={{ overflow: 'hidden', flex: 1, minWidth: 0, paddingBottom: 2 }}>
|
||||
{/* 月份标签行:绝对定位在每月起始列上方。 */}
|
||||
<div style={{ position: 'relative', height: 14, marginBottom: 2 }}>
|
||||
{weeks.map((week, i) =>
|
||||
week.monthStart != null ? (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: i * step,
|
||||
fontSize: cell < 9 ? 8 : 9,
|
||||
color: 'var(--ol-ink-4)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{monthLabels[week.monthStart]}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: cellGap }}>
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} style={{ display: 'flex', flexDirection: 'column', gap: cellGap }}>
|
||||
{week.days.map((day, di) =>
|
||||
day.value == null ? (
|
||||
<span key={di} style={{ width: cell, height: cell }} />
|
||||
) : (
|
||||
<span
|
||||
key={di}
|
||||
title={`${dateDisplay(day.date)} · ${valueDisplay(day.value)}`}
|
||||
style={{
|
||||
width: cell,
|
||||
height: cell,
|
||||
borderRadius: cell < 8 ? 2 : 2.5,
|
||||
background: colorOf(day.value),
|
||||
boxShadow: 'inset 0 0 0 0.5px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Kbd.tsx — 键帽显示(shadcn Kbd 的纯 React 版,用户拍板的快捷键展示标准)。
|
||||
// 语义用真 <kbd> 元素;键帽视觉走主题 token(浅/深色自适应),底边阴影出立体感。
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
export function Kbd({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
||||
return <kbd style={{ ...kbdStyle, ...style }}>{children}</kbd>;
|
||||
}
|
||||
|
||||
/** 组合键:一组键帽并排(shadcn KbdGroup),如「左 ⌥」「Space」。 */
|
||||
export function KbdGroup({ keys, style }: { keys: string[]; style?: CSSProperties }) {
|
||||
return (
|
||||
<span style={{ ...groupStyle, ...style }}>
|
||||
{keys.map((key, i) => (
|
||||
<Kbd key={`${key}-${i}`}>{key}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const kbdStyle: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 20,
|
||||
height: 21,
|
||||
padding: '0 6px',
|
||||
borderRadius: 5,
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1,
|
||||
fontWeight: 500,
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
color: 'var(--ol-ink-2)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
// 键帽立体感:底边多一线阴影。
|
||||
boxShadow: '0 1.5px 0 var(--ol-line), 0 0 0 0.5px rgba(0,0,0,0.02)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const groupStyle: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -38,8 +38,8 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
|
||||
background: 'rgba(15,17,22,0.32)',
|
||||
backdropFilter: 'blur(8px) saturate(140%)',
|
||||
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 28,
|
||||
// Drawer 逻辑(用户拍板):风格市场从右缘滑入的全高抽屉,右上角 ✕ 关闭。
|
||||
display: 'flex', alignItems: 'stretch', justifyContent: 'flex-end',
|
||||
zIndex: 50,
|
||||
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
|
||||
}}
|
||||
@@ -47,15 +47,20 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', maxWidth: 1080, height: '100%', maxHeight: 720,
|
||||
background: 'var(--ol-surface)',
|
||||
borderRadius: 14,
|
||||
border: '0.5px solid rgba(0,0,0,.08)',
|
||||
boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
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-modal-card-in 0.24s var(--ol-motion-spring)',
|
||||
animation: 'ol-drawer-in-right 0.32s var(--ol-motion-spring)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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)',
|
||||
};
|
||||
@@ -2,7 +2,9 @@
|
||||
//
|
||||
// 重构(2026-05):原本是「外层弹窗侧栏 + 设置页内层侧栏」双层嵌套,用户点
|
||||
// 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 /
|
||||
// 个性化 / 关于 六个 tab + 帮助外链组。每个 tab 的内容见 pages/settings/。
|
||||
// 关于 五个 tab + 帮助外链组。每个 tab 的内容见 pages/settings/tabs.tsx,
|
||||
// 分组原则:按「用户带着什么问题来」归类 —— 怎么录(通用)/ 识别润色由谁提供
|
||||
// (服务,含本地模型)/ 数据去哪了(隐私)/ 实验功能(高级)/ 版本与更新(关于)。
|
||||
//
|
||||
// 设计原则:每个可见控件都必须可用。
|
||||
|
||||
@@ -14,8 +16,7 @@ import { useSavedToastListener } from '../lib/savedEvent';
|
||||
import { openExternal } from '../lib/ipc';
|
||||
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||
import type { OS } from './WindowChrome';
|
||||
import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
|
||||
import { AboutSection } from '../pages/settings/AboutSection';
|
||||
import { AboutTab, GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
|
||||
import { chipSelectedStyle } from '../pages/settings/shared';
|
||||
|
||||
// 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。
|
||||
@@ -81,9 +82,8 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
style={{
|
||||
position: mobile ? 'fixed' : 'absolute',
|
||||
inset: 0,
|
||||
// 白/实底哲学:遮罩只做半透明压暗(overlay token),不再叠 backdrop-filter 玻璃模糊。
|
||||
background: mobile ? 'var(--ol-surface)' : 'var(--ol-overlay-bg)',
|
||||
backdropFilter: mobile ? 'none' : 'blur(8px) saturate(140%)',
|
||||
WebkitBackdropFilter: mobile ? 'none' : 'blur(8px) saturate(140%)',
|
||||
display: 'flex',
|
||||
alignItems: mobile ? 'stretch' : 'center',
|
||||
justifyContent: mobile ? 'stretch' : 'center',
|
||||
@@ -231,7 +231,8 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
cursor: 'default',
|
||||
transition: 'background 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--ol-settings-close-hover-bg)')}
|
||||
// hover 底色适配白/实底内容区:旧 token 是白玻璃 rgba(白底上不可见),改用 surface-2。
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--ol-surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
title={t('common.close')}>
|
||||
<Icon name="close" size={14} />
|
||||
@@ -245,7 +246,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
)}
|
||||
|
||||
<div
|
||||
className="ol-thinscroll"
|
||||
className="ol-thinscroll ol-scroll-fade"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -260,7 +261,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
{section === 'services' && <ServicesTab />}
|
||||
{section === 'privacy' && <PrivacyTab />}
|
||||
{section === 'advanced' && <AdvancedTab />}
|
||||
{section === 'about' && <AboutSection />}
|
||||
{section === 'about' && <AboutTab />}
|
||||
</div>
|
||||
{mobile && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '0.5px solid var(--ol-line-soft)' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatComboLabel, getHotkeyTriggerLabel, modifiersFromPressedCodes, shortcutFromLegacyTrigger } from '../lib/hotkey';
|
||||
import { formatComboParts, getHotkeyTriggerLabel, modifiersFromPressedCodes, shortcutFromLegacyTrigger } from '../lib/hotkey';
|
||||
import { KbdGroup } from './Kbd';
|
||||
import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc';
|
||||
import type { HotkeyTrigger, ShortcutBinding } from '../lib/types';
|
||||
|
||||
@@ -189,9 +190,8 @@ export function ShortcutRecorder({
|
||||
return (
|
||||
<div style={rootStyle}>
|
||||
<div style={recorderRowStyle}>
|
||||
<span style={{ padding: '4px 10px', borderRadius: 6, background: 'rgba(0,0,0,0.06)', fontSize: 13, fontFamily: 'var(--ol-font-mono)', fontWeight: 500, color: 'var(--ol-ink)' }}>
|
||||
{formatComboLabel(value)}
|
||||
</span>
|
||||
{/* 键帽逐键展示(Kbd 组件,用户拍板的快捷键展示标准),替代整块灰底文本。 */}
|
||||
<KbdGroup keys={formatComboParts(value)} />
|
||||
<div style={controlsGroupStyle}>
|
||||
{onDisable && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
// SiriGL.tsx — Apple 新版 Siri 动画的 WebGL 复刻,胶囊「纯光效」形态的渲染核心。
|
||||
//
|
||||
// Shader 移植自 github.com/aaaa-zhen/siri-glsl(MIT,siri-wave.html):
|
||||
// - wave:siriWaveCore 光谱声波 —— 4 次光谱采样色散 + Lorentzian 发光线。
|
||||
// 原版 RESOLVED / 假音频信号改为 uniform:uLevel 接真实麦克风电平(capsule:state
|
||||
// 的 60Hz RMS),uResolved 1→0 时波形向圆心汇聚成呼吸光点(录音 → 思考的过渡)。
|
||||
// 原版末尾 `col *= res` 会让 res=0 全黑,这里改成保底亮度让汇聚后的光点常亮。
|
||||
// - orb:siriFluidDotsCore 流体圆点 —— 6 个 metaball smooth-min 融合 + 周期
|
||||
// 聚拢/爆发,即官方「思考中」的圆形图标,原样移植。
|
||||
//
|
||||
// 渲染框架与 demo 一致:单个大三角形铺满 + fragment shader,uniform 仅 4 个。
|
||||
// 输出加了 alpha(取 RGB 最大分量),光效直接浮在透明窗口上,无需底色。
|
||||
// 生命周期:挂载启动 rAF,卸载 cancel + 释放 GL 资源(不可见即零 GPU,#470 同款原则)。
|
||||
|
||||
import { useEffect, useRef, type CSSProperties } from 'react';
|
||||
|
||||
export type SiriGLMode = 'wave' | 'orb';
|
||||
|
||||
interface SiriGLProps {
|
||||
mode: SiriGLMode;
|
||||
/** wave 专用:原始 RMS 电平 0..1,内部做门限 + 攻快释慢平滑。 */
|
||||
level?: number;
|
||||
/** wave 专用:true=波形展开(录音),false=向圆心汇聚成呼吸光点(思考)。 */
|
||||
resolved?: boolean;
|
||||
/**
|
||||
* wave 专用:预备态。true = 麦克风尚未就绪,光条走柔和「待命」呼吸(忽略真实电平),
|
||||
* 暗示用户稍候;麦克风就绪后置 false,改接真实电平,光条「点亮」进入正式录音。
|
||||
*/
|
||||
warming?: boolean;
|
||||
/** wave 专用:预备态预期时长(ms),驱动展开动画的预测节奏。见 Capsule warmupMs。 */
|
||||
warmupMs?: number;
|
||||
/**
|
||||
* 动画时间倍率(默认 1)。shader 时间由内部按 dt×speed 累积而非取墙钟,
|
||||
* 因此变速是连续的:思考中 >1 加速转动,收尾回落到标准速度。
|
||||
*/
|
||||
speed?: number;
|
||||
/** orb 专用:true = 六点向圆心合并成一颗圆(插入完成的收尾动画)。 */
|
||||
merging?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/** 内部渲染分辨率倍率(乘在 devicePixelRatio 上),demo 同款取值,越低越省 GPU。 */
|
||||
const RENDER_SCALE = 0.75;
|
||||
|
||||
/**
|
||||
* 预备态光条的「收拢度」:wave 的 uResolved 停在这个低值 —— 光条向中心聚拢、明显未展开
|
||||
* (视觉上就是「加载中,还没好」)。麦克风就绪后 uResolved 升到 1,光条展开点亮成完整
|
||||
* 声波,作为「准备完成、可以开口」的唯一信号。取值偏低才够「未成形」,但不取 0(0 是思
|
||||
* 考态的中心光点,避免撞脸)。
|
||||
*/
|
||||
const WARMING_RESOLVED = 0.2;
|
||||
|
||||
const VERTEX_SRC = 'attribute vec2 aPos; void main(){ gl_Position=vec4(aPos,0.0,1.0); }';
|
||||
|
||||
const WAVE_FRAGMENT_SRC = `
|
||||
precision highp float;
|
||||
uniform vec2 iResolution; uniform float iTime;
|
||||
uniform float uResolved;
|
||||
uniform float uLevel;
|
||||
const float PI = 3.14159265359;
|
||||
const float AMPLITUDE=0.32, FREQ=1.1, ABER_FREQ=1.0, SPEED=2.4, WAVE_SCALE=0.6;
|
||||
const float ABERRATION=2.6, THICKNESS=3.0, INTENSITY=2., FALLOFF=1.7;
|
||||
const float EDGE_MASK=0.4, EDGE_INSET=0.0, BAND_FILL=30000.0, BAND_THICK=0.08, SOFTNESS=2.5;
|
||||
const float LOW_AMP=6.0, LOW_INT=1.5, MID_ABER=0.8, MID_ABAMP=0.05, MID_SOFT=0.4;
|
||||
const float HIGH_ABER=0.5, HIGH_ABAMP=0.06, UNRES_SCALE=0.14;
|
||||
vec3 spectral4(int s){ float x=float(s);
|
||||
return clamp(vec3(abs(x-3.0)-1.0, 2.0-abs(x-2.0), 2.0-abs(x-4.0)), 0.0, 1.0); }
|
||||
void main(){
|
||||
vec2 R=iResolution.xy; float aspect=R.x/R.y;
|
||||
vec2 p=(gl_FragCoord.xy+0.5)*2.0/R-1.0; p.x*=aspect;
|
||||
float yScreen=p.y; p/=max(WAVE_SCALE,0.1);
|
||||
float t=iTime;
|
||||
float dv=clamp(uLevel,0.0,1.0);
|
||||
float low =clamp(0.45+0.45*sin(t*0.8)*sin(t*0.37+1.0),0.0,1.0)*dv;
|
||||
float mid =clamp(0.40+0.40*sin(t*1.7+2.0)*sin(t*0.53),0.0,1.0)*dv;
|
||||
float high=clamp(0.30+0.30*sin(t*2.9+4.0)*sin(t*0.71+2.0),0.0,1.0)*dv;
|
||||
float res=clamp(uResolved,0.0,1.0);
|
||||
float drift=mod(t,20.0*PI)*SPEED;
|
||||
/* 汇聚 = 「四周向中间收缩」(用户拍板):波形坐标随 res 下降横向放大,
|
||||
可见波纹被从两端挤向圆心,而不是原地塌扁 + 相位继续往两边跑(脉冲感)。
|
||||
光点距离场仍用未收缩的 p,保持正圆。 */
|
||||
vec2 pw=p; pw.x*=mix(5.0,1.0,res);
|
||||
float xN=pw.x/max(aspect,1.0);
|
||||
float env=cos(PI*0.5*min(abs(0.9*xN),1.0)); env*=env;
|
||||
float A1=(AMPLITUDE*mix(0.14,1.0,dv))+0.01*low*LOW_AMP;
|
||||
float A2=A1+mid*MID_ABAMP+high*HIGH_ABAMP;
|
||||
float AB=(ABERRATION+mid*MID_ABER+high*HIGH_ABER)*res;
|
||||
float th=mix(0.1,0.01*THICKNESS,res);
|
||||
float inten=mix(0.1,0.01*(INTENSITY+low*LOW_INT),res);
|
||||
float soft=0.01*res*max(0.0,SOFTNESS+mid*MID_SOFT);
|
||||
float dUnres=max(length(p)-mix(0.14,UNRES_SCALE,res),0.0);
|
||||
float yMain=A1*env*res*sin(pw.x*FREQ+drift);
|
||||
float bandFillTh=max(BAND_THICK,1e-4);
|
||||
float bandAmt=1e-4*BAND_FILL*inten;
|
||||
vec3 num=vec3(0.0),den=vec3(0.0);
|
||||
for(int s=0;s<4;s++){
|
||||
vec3 hue=mix(vec3(1.0),spectral4(s),res); den+=hue;
|
||||
float ab=mix(-AB,AB,float(s)/3.0);
|
||||
float yL=A2*env*res*sin(pw.x*ABER_FREQ+drift+ab);
|
||||
float d=mix(dUnres,abs(p.y-yL),res);
|
||||
float lor=mix(1.0/(1.0+(0.02*d)*(0.02*d)),1.0,res);
|
||||
float line=inten/(sqrt(d*d+soft*soft)+th);
|
||||
float lo=min(yMain,yL),hi=max(yMain,yL);
|
||||
float dBand=max(0.0,max(p.y-hi,lo-p.y));
|
||||
float band=bandAmt/(dBand+bandFillTh);
|
||||
num+=hue*lor*(line+band);
|
||||
}
|
||||
vec3 col=num/den;
|
||||
float dM=mix(dUnres,abs(p.y-yMain),res);
|
||||
float lorM=mix(1.0/(1.0+(0.02*dM)*(0.02*dM)),1.0,res);
|
||||
/* 原版 boost=(1-res)*(14*low+4):汇聚光点的大光晕会铺满透明窗口、在窗口
|
||||
边界被硬裁出直边。压到含蓄水平 —— 转场一切向内收,不向外扩。 */
|
||||
float boost=(1.0-res)*(3.0*low+1.2);
|
||||
col+=0.5*inten*(lorM+boost)/(sqrt(dM*dM+soft*soft)+th);
|
||||
col=pow(max(col,0.0),vec3(1.5));
|
||||
float emT=clamp((abs(yScreen)-1.0+EDGE_INSET)/(-max(EDGE_MASK,1e-4)),0.0,1.0);
|
||||
float em=emT*emT*(3.0-2.0*emT);
|
||||
float gauss=exp(-pow(xN*FALLOFF,2.0));
|
||||
/* 遮罩全程生效(原版 res→0 时遮罩失效会让收拢中段的残波漏到两侧)。 */
|
||||
col*=em*gauss;
|
||||
col*=mix(0.55,1.0,res);
|
||||
float a=clamp(max(col.r,max(col.g,col.b)),0.0,1.0);
|
||||
gl_FragColor=vec4(col,a);
|
||||
}`;
|
||||
|
||||
// 相对原版 siriFluidDotsCore 的两处刻意修改(用户拍板):
|
||||
// 1. 删除 12s 周期的 gather→burst 爆发 —— 冲击波会顶到方形画布边缘被硬裁;
|
||||
// 思考态只保留纯粹的转动 + metaball 融合。
|
||||
// 2. 聚拢改为 uniform uGather 驱动的「出场」:1=六点全聚在圆心(视觉上就是
|
||||
// wave 汇聚后的那颗光点),0=散开成环 —— 光条收拢的光点由此平滑「化开」
|
||||
// 成思考圆点,中间没有任何跳变或弹入冲击。
|
||||
const ORB_FRAGMENT_SRC = `
|
||||
precision highp float;
|
||||
uniform vec2 iResolution; uniform float iTime;
|
||||
uniform float uGather;
|
||||
const float TAU=6.28318530718;
|
||||
const int N=6;
|
||||
const float SMOOTH_K=0.08, INTENSITY=0.0025, FALLOFF_P=1.35, FADE_START=0.02, FADE_END=0.56;
|
||||
const float ABERR=0.005; const vec3 SPECTRAL=vec3(0.0,0.5,1.0)*ABERR;
|
||||
const float HUE_SPEED=0.06, COLOR_K=0.5, SAT=0.01, HUE_SPAN=0.667;
|
||||
const float MERGE_PERIOD=6.0, STAGGER=0.33, HOLD=0.0;
|
||||
const float W=4.6, L=3.2, PIERCE=0.12, RECOIL=0.035, REC_LAG=0.11;
|
||||
const float GATHER_R=0.008, GATHER_DIM=0.85;
|
||||
float hash11(float n){ return fract(sin(n*127.1+311.7)*43758.5453); }
|
||||
float settleWL(float tau,float w,float l){ if(tau<=0.0) return 0.0; return 1.0-exp(-l*tau)*cos(w*tau); }
|
||||
float settle(float tau){ return settleWL(tau,W,L); }
|
||||
float smin(float a,float b,float k){ float h=max(k-abs(a-b),0.0)/k; return min(a,b)-h*h*k*0.25; }
|
||||
vec3 hue2rgb(float h){ h=fract(h);
|
||||
float r=clamp(abs(h*6.0-3.0)-1.0,0.0,1.0);
|
||||
float g=clamp(2.0-abs(h*6.0-2.0),0.0,1.0);
|
||||
float b=clamp(2.0-abs(h*6.0-4.0),0.0,1.0);
|
||||
return vec3(r,g,b); }
|
||||
float dotR(float fi,float seed,float t){ return 0.036+0.010*sin(t*1.3+seed*TAU)+0.005*sin(t*2.4+fi*1.3); }
|
||||
float dotSD(vec2 p,vec2 pos,float r,float t,float fi,float shapeDamp){
|
||||
vec2 d=p-pos;
|
||||
float sq=0.075*(0.5+0.5*sin(t*0.9+fi*2.0))*shapeDamp;
|
||||
float ca=cos(t*0.35+fi),sa=sin(t*0.35+fi);
|
||||
d=mat2(ca,-sa,sa,ca)*d;
|
||||
d*=vec2(1.0+sq,1.0-sq);
|
||||
return length(d)-r; }
|
||||
vec3 scene(vec2 p,float t){
|
||||
float k=floor(t/MERGE_PERIOD);
|
||||
float u=fract(t/MERGE_PERIOD);
|
||||
float te=u*MERGE_PERIOD;
|
||||
float gC=clamp(uGather,0.0,1.0);
|
||||
float gBright=mix(1.0,GATHER_DIM,gC)*(1.0+0.30*gC);
|
||||
vec3 total3=vec3(1e5);
|
||||
vec3 cAcc=vec3(0.0);
|
||||
float wAcc=1e-6;
|
||||
for(int i=0;i<N;i++){
|
||||
float fi=float(i);
|
||||
float seed=hash11(fi);
|
||||
float ang=fi/float(N)*TAU+t*0.35;
|
||||
vec2 dir=vec2(cos(ang),sin(ang));
|
||||
float R=0.17+0.010*sin(t*1.0)+0.007*sin(t*1.3+seed*TAU);
|
||||
float pairId=mod(fi,3.0);
|
||||
float moverLow=mod(k+pairId,2.0);
|
||||
float isMover=(fi<2.5)?step(moverLow,0.5):step(0.5,moverLow);
|
||||
float goStart=pairId*STAGGER;
|
||||
float retStart=3.0*STAGGER+HOLD+pairId*STAGGER;
|
||||
float m=(settle(te-goStart)-settle(te-retStart))*isMover;
|
||||
float rec=(settle(te-goStart-REC_LAG)-settle(te-retStart-REC_LAG))*(1.0-isMover);
|
||||
float rSelf=dotR(fi,seed,t);
|
||||
rSelf=mix(rSelf,0.036,gC);
|
||||
float fj=mod(fi+3.0,6.0);
|
||||
float rPart=dotR(fj,hash11(fj),t);
|
||||
float deep=-(R+RECOIL)-PIERCE*rPart;
|
||||
float radial=mix(R,deep,m)+RECOIL*rec;
|
||||
radial=mix(radial,GATHER_R,gC);
|
||||
vec2 pos=radial*dir;
|
||||
float sdR=dotSD(p-SPECTRAL.r*dir,pos,rSelf,t,fi,1.0-gC);
|
||||
float sdG=dotSD(p-SPECTRAL.g*dir,pos,rSelf,t,fi,1.0-gC);
|
||||
float sdB=dotSD(p-SPECTRAL.b*dir,pos,rSelf,t,fi,1.0-gC);
|
||||
total3=vec3(smin(total3.r,sdR,SMOOTH_K),
|
||||
smin(total3.g,sdG,SMOOTH_K),
|
||||
smin(total3.b,sdB,SMOOTH_K));
|
||||
float hue=fract(fi/float(N)+t*HUE_SPEED)*HUE_SPAN;
|
||||
vec3 dotCol=mix(vec3(1.0),hue2rgb(hue),SAT);
|
||||
float w=exp(-sdG*COLOR_K);
|
||||
cAcc+=w*dotCol;
|
||||
wAcc+=w;
|
||||
}
|
||||
vec3 sd3=max(total3,vec3(0.0))+1e-4;
|
||||
vec3 core3=clamp(INTENSITY/pow(sd3,vec3(FALLOFF_P)),0.0,1.0);
|
||||
vec3 edge3=1.0-smoothstep(vec3(FADE_START),vec3(FADE_END),sd3);
|
||||
vec3 bright=core3*edge3*gBright;
|
||||
return bright*(cAcc/wAcc);
|
||||
}
|
||||
void main(){
|
||||
vec2 res=iResolution.xy;
|
||||
vec2 p=(2.0*gl_FragCoord.xy-res)/min(res.x,res.y);
|
||||
float t=iTime;
|
||||
p/=1.0+0.03*sin(t*1.0);
|
||||
vec3 col=scene(p,t);
|
||||
col*=1.0+0.05*sin(t*1.0+1.0);
|
||||
col=pow(col,vec3(1.0/1.2));
|
||||
col=min(col,1.0);
|
||||
float a=clamp(max(col.r,max(col.g,col.b)),0.0,1.0);
|
||||
gl_FragColor=vec4(col,a);
|
||||
}`;
|
||||
|
||||
let webglProbe: boolean | null = null;
|
||||
|
||||
/** 一次性探测 WebGL 可用性;失败时调用方退回旧胶囊 UI。 */
|
||||
export function isWebGLAvailable(): boolean {
|
||||
if (webglProbe != null) return webglProbe;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
webglProbe = canvas.getContext('webgl') != null;
|
||||
} catch {
|
||||
webglProbe = false;
|
||||
}
|
||||
return webglProbe;
|
||||
}
|
||||
|
||||
let shadersWarmed = false;
|
||||
|
||||
/**
|
||||
* 空闲预热:在 1×1 的离屏 context 里把两个 shader 各编译一次然后立即释放。
|
||||
* GPU 驱动(Metal / ANGLE)按源码缓存编译产物,正式挂载时命中缓存,编译从
|
||||
* 几十毫秒降到近零 —— 消除「按下热键后光条慢一拍」的首次延迟。
|
||||
*/
|
||||
export function warmUpSiriShaders(): void {
|
||||
if (shadersWarmed || !isWebGLAvailable()) return;
|
||||
shadersWarmed = true;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const gl = canvas.getContext('webgl');
|
||||
if (!gl) return;
|
||||
for (const src of [WAVE_FRAGMENT_SRC, ORB_FRAGMENT_SRC]) {
|
||||
const vs = gl.createShader(gl.VERTEX_SHADER);
|
||||
const fs = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
const program = gl.createProgram();
|
||||
if (!vs || !fs || !program) continue;
|
||||
gl.shaderSource(vs, VERTEX_SRC);
|
||||
gl.compileShader(vs);
|
||||
gl.shaderSource(fs, src);
|
||||
gl.compileShader(fs);
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
} catch {
|
||||
// 预热失败无碍:正式挂载时照常编译。
|
||||
}
|
||||
}
|
||||
|
||||
/** AudioBars 同款心理声学映射:门限静音底噪 + smoothstep + pow 提升小音量表现。 */
|
||||
function visualVoice(raw: number): number {
|
||||
const gate = 0.012;
|
||||
const ceiling = 0.34;
|
||||
const gated = Math.min(1, Math.max(0, (raw - gate) / (ceiling - gate)));
|
||||
const eased = gated * gated * (3 - 2 * gated);
|
||||
return Math.pow(eased, 0.42);
|
||||
}
|
||||
|
||||
export function SiriGL({ mode, level, resolved, warming, warmupMs, speed, merging, className, style }: SiriGLProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
// 60Hz 的 level 更新走 ref 桥接:render 只同步数值,绘制循环在 rAF 里读,
|
||||
// 不因 props 变化重建 GL 管线。
|
||||
const levelRef = useRef(0);
|
||||
const resolvedRef = useRef(1);
|
||||
const speedRef = useRef(1);
|
||||
const mergingRef = useRef(0);
|
||||
const warmingRef = useRef(0);
|
||||
const warmupMsRef = useRef(150);
|
||||
levelRef.current = Math.min(1, Math.max(0, level ?? 0));
|
||||
resolvedRef.current = resolved === false ? 0 : 1;
|
||||
speedRef.current = speed ?? 1;
|
||||
mergingRef.current = merging === true ? 1 : 0;
|
||||
warmingRef.current = warming === true ? 1 : 0;
|
||||
warmupMsRef.current = warmupMs ?? 150;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
const gl = canvas.getContext('webgl', {
|
||||
alpha: true,
|
||||
premultipliedAlpha: true,
|
||||
antialias: false,
|
||||
});
|
||||
if (!gl) return undefined;
|
||||
|
||||
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('[SiriGL] 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, mode === 'wave' ? WAVE_FRAGMENT_SRC : ORB_FRAGMENT_SRC);
|
||||
if (!vs || !fs) return undefined;
|
||||
const program = gl.createProgram();
|
||||
if (!program) 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));
|
||||
return undefined;
|
||||
}
|
||||
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 uResolvedLoc = gl.getUniformLocation(program, 'uResolved');
|
||||
const uLevelLoc = gl.getUniformLocation(program, 'uLevel');
|
||||
const uGatherLoc = gl.getUniformLocation(program, 'uGather');
|
||||
|
||||
// 绘制侧的缓动状态:电平攻快释慢(VU 表手感),resolved 指数趋近(「慢慢汇聚」)。
|
||||
// shader 时间按 dt×speed 累积(非墙钟),变速连续无跳变。
|
||||
// orb 的 uGather 完整生命周期(用户拍板):出场 gather=1 全聚圆心(接住 wave
|
||||
// 收缩成的光点)→ 稳住一拍后缓缓「从中心散开」成环转动 → merging=true 时
|
||||
// 干脆地(k=4)合并回中央一颗圆,随淡出消失。
|
||||
const GATHER_HOLD_S = 0.3;
|
||||
let smoothLevel = 0;
|
||||
// 预备态一挂载就从收拢态起步(未成形=加载中),避免「先展开一下又收拢」的突兀;
|
||||
// 麦克风就绪后再展开到 resolvedRef。
|
||||
let smoothResolved = warmingRef.current > 0.5 ? WARMING_RESOLVED : resolvedRef.current;
|
||||
// 预测式展开进度(0=收拢, 1=展开):warming 入场从 0 起爬,非 warming 直接 1。
|
||||
let warmProgress = warmingRef.current > 0.5 ? 0 : 1;
|
||||
let smoothSpeed = speedRef.current;
|
||||
let gather = 1;
|
||||
let shaderTime = 0;
|
||||
let elapsed = 0;
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
|
||||
const frame = (now: number) => {
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
elapsed += dt;
|
||||
smoothSpeed += (speedRef.current - smoothSpeed) * (1 - Math.exp(-dt * 2.5));
|
||||
shaderTime += dt * smoothSpeed;
|
||||
const t = shaderTime;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2) * RENDER_SCALE;
|
||||
const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
|
||||
const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
gl.viewport(0, 0, w, h);
|
||||
}
|
||||
|
||||
// 汇聚态(resolved→0)时电平失去含义,切换成含蓄的慢呼吸信号驱动光点搏动。
|
||||
const thinking = resolvedRef.current < 0.5;
|
||||
const warmingNow = warmingRef.current > 0.5;
|
||||
// 电平:预备态走低幅「加载脉动」(暗、慢,明显未就绪),思考态呼吸,录音态接真实电平。
|
||||
const target = warmingNow
|
||||
? 0.12 + 0.06 * Math.sin(t * 3.0)
|
||||
: thinking
|
||||
? 0.14 + 0.07 * Math.sin(t * 2.2)
|
||||
: visualVoice(levelRef.current);
|
||||
const attack = target > smoothLevel ? 14 : 5;
|
||||
smoothLevel += (target - smoothLevel) * (1 - Math.exp(-dt * attack));
|
||||
// 预测式展开(用户方案):入场展开不再死等就绪信号(那会「亮起→卡一下→突然展开」),
|
||||
// 而是按历史平均加载时长 warmupMs 从按下就平滑推进:
|
||||
// · 未就绪(warming):warmProgress 按 warmupMs 匀速慢爬,cap 0.9(留一截给就绪收尾);
|
||||
// · 就绪(warming→false):快速补到 1 —— 就绪早=剩得多,视觉上「迅速展开完成」;
|
||||
// · 加载超长:停在 0.9 等着(用户说的「没办法」的情况),光条明显未满=还没好。
|
||||
const warmupSec = Math.max(0.06, warmupMsRef.current / 1000);
|
||||
if (warmingNow) {
|
||||
warmProgress = Math.min(0.9, warmProgress + dt / warmupSec);
|
||||
} else {
|
||||
warmProgress += (1 - warmProgress) * (1 - Math.exp(-dt * 9));
|
||||
}
|
||||
// resolved:思考态(resolvedRef→0)走原「从容汇聚」;入场/录音由 warmProgress 从收拢
|
||||
// (WARMING_RESOLVED)展开到满,smoothResolved 紧跟(展开节奏已由 warmProgress 控速)。
|
||||
const targetResolved = thinking
|
||||
? resolvedRef.current
|
||||
: WARMING_RESOLVED + (1 - WARMING_RESOLVED) * warmProgress;
|
||||
const resolvedK = thinking ? 2.2 : 9.0;
|
||||
smoothResolved += (targetResolved - smoothResolved) * (1 - Math.exp(-dt * resolvedK));
|
||||
// 出场 hold 之后才开始散开;合并(目标 1)快、散开(目标 0)缓。
|
||||
if (mergingRef.current === 1) {
|
||||
gather += (1 - gather) * (1 - Math.exp(-dt * 4.0));
|
||||
} else if (elapsed > GATHER_HOLD_S) {
|
||||
gather += (0 - gather) * (1 - Math.exp(-dt * 1.6));
|
||||
}
|
||||
|
||||
gl.uniform2f(uResolution, w, h);
|
||||
gl.uniform1f(uTime, t);
|
||||
if (uResolvedLoc) gl.uniform1f(uResolvedLoc, smoothResolved);
|
||||
if (uLevelLoc) gl.uniform1f(uLevelLoc, smoothLevel);
|
||||
if (uGatherLoc) gl.uniform1f(uGatherLoc, gather);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
// 首帧同步绘制(不等下一个 vsync):挂载即有画面,热键按下的响应少一帧延迟。
|
||||
frame(performance.now());
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={className}
|
||||
style={{ display: 'block', pointerEvents: 'none', ...style }}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// ThinkingDots.tsx — 六圆点「思考中」指示,全应用统一的思考视觉语言。
|
||||
//
|
||||
// 与胶囊的 WebGL 流体圆点(SiriGL orb)同一意象:六个彩点绕圆心转动 + 轻微
|
||||
// 聚散呼吸。面板/列表里的指示尺寸小(16~28px),WebGL orb 在这个尺寸下细节
|
||||
// 不可辨且每个实例占一个 GL context,所以这里用纯 CSS 实现 —— 只动
|
||||
// transform/opacity(合成器友好),任意尺寸清晰,浅色底上依然可见(实色点)。
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
interface ThinkingDotsProps {
|
||||
/** 外接圆直径(px),默认 20。 */
|
||||
size?: number;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/** 六点色板:取 Siri 光谱的低饱和段,深浅主题下都可读。 */
|
||||
const DOT_COLORS = ['#5b8def', '#8b6ff2', '#c96fd6', '#e08787', '#d9a75f', '#5fb8a8'];
|
||||
|
||||
export function ThinkingDots({ size = 20, style }: ThinkingDotsProps) {
|
||||
const dot = Math.max(2.5, size * 0.17);
|
||||
const radius = size * 0.31;
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
width: size,
|
||||
height: size,
|
||||
flexShrink: 0,
|
||||
animation: 'ol-thinking-spin 1.7s linear infinite, ol-thinking-breathe 2.8s ease-in-out infinite',
|
||||
willChange: 'transform',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{DOT_COLORS.map((color, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: dot,
|
||||
height: dot,
|
||||
marginLeft: -dot / 2,
|
||||
marginTop: -dot / 2,
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
transform: `rotate(${i * 60}deg) translateY(${-radius}px)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const THINKING_DOTS_KEYFRAMES = `
|
||||
@keyframes ol-thinking-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes ol-thinking-breathe {
|
||||
0%, 100% { scale: 1; opacity: 1; }
|
||||
50% { scale: .82; opacity: .8; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[style*="ol-thinking-spin"] { animation: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
if (typeof document !== 'undefined' && !document.getElementById('ol-thinking-dots-style')) {
|
||||
const tag = document.createElement('style');
|
||||
tag.id = 'ol-thinking-dots-style';
|
||||
tag.textContent = THINKING_DOTS_KEYFRAMES;
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Tooltip.tsx — 统一的悬浮提示,macOS 菜单式「预热」行为:
|
||||
//
|
||||
// - 首次 hover:延迟 600ms 才出现 —— 日常操作扫过按钮不触发一堆提示;
|
||||
// - 预热窗口内(某个 tooltip 正显示、或刚关闭 <300ms)再 hover 相邻目标:
|
||||
// 0 延迟即时切换 —— 想连续查看各菜单功能说明时不用每个都等。
|
||||
//
|
||||
// 预热状态是模块级共享的,所有 Tooltip 实例天然联动。渲染用 portal + fixed
|
||||
// 定位,不受祖先 overflow / transform / backdrop-filter 包含块影响。
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
/** 首次 hover 到出现的延迟。 */
|
||||
const WARM_DELAY_MS = 600;
|
||||
/** tooltip 关闭后预热状态的存续窗口:这段时间内 hover 相邻目标即时显示。 */
|
||||
const WARM_LINGER_MS = 300;
|
||||
|
||||
let warmUntil = 0;
|
||||
|
||||
interface TooltipProps {
|
||||
content: ReactNode;
|
||||
/** 提示出现在锚点的哪一侧,默认 right(适合左侧栏菜单)。 */
|
||||
placement?: 'right' | 'top' | 'bottom';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Tooltip({ content, placement = 'right', children }: TooltipProps) {
|
||||
const anchorRef = useRef<HTMLSpanElement>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const show = () => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
if (placement === 'right') {
|
||||
setPos({ x: rect.right + 10, y: rect.top + rect.height / 2 });
|
||||
} else if (placement === 'top') {
|
||||
setPos({ x: rect.left + rect.width / 2, y: rect.top - 8 });
|
||||
} else {
|
||||
setPos({ x: rect.left + rect.width / 2, y: rect.bottom + 8 });
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (timerRef.current != null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setPos(prev => {
|
||||
if (prev) warmUntil = Date.now() + WARM_LINGER_MS;
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const onEnter = () => {
|
||||
if (Date.now() < warmUntil) {
|
||||
show();
|
||||
return;
|
||||
}
|
||||
timerRef.current = window.setTimeout(show, WARM_DELAY_MS);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
// display:grid 单格包装:span 尺寸与子元素一致(display:contents 的 rect 恒为 0
|
||||
// 无法定位),又不给 flex/grid 父容器引入额外的布局盒差异。
|
||||
<span
|
||||
ref={anchorRef}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={hide}
|
||||
onMouseDown={hide}
|
||||
style={{ display: 'grid' }}
|
||||
>
|
||||
{children}
|
||||
{pos != null &&
|
||||
createPortal(
|
||||
<span role="tooltip" style={{ ...bubbleStyle, ...placementStyle(placement, pos) }}>
|
||||
{content}
|
||||
</span>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function placementStyle(placement: 'right' | 'top' | 'bottom', pos: { x: number; y: number }): CSSProperties {
|
||||
if (placement === 'right') {
|
||||
return { left: pos.x, top: pos.y, transform: 'translateY(-50%)' };
|
||||
}
|
||||
if (placement === 'top') {
|
||||
return { left: pos.x, top: pos.y, transform: 'translate(-50%, -100%)' };
|
||||
}
|
||||
return { left: pos.x, top: pos.y, transform: 'translateX(-50%)' };
|
||||
}
|
||||
|
||||
const bubbleStyle: CSSProperties = {
|
||||
position: 'fixed',
|
||||
zIndex: 1000,
|
||||
maxWidth: 260,
|
||||
padding: '5px 9px',
|
||||
borderRadius: 8,
|
||||
fontSize: 11.5,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.45,
|
||||
color: 'var(--ol-ink)',
|
||||
background: 'var(--ol-glass-bg-strong)',
|
||||
backdropFilter: 'blur(12px) saturate(150%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(150%)',
|
||||
border: '0.5px solid var(--ol-glass-border)',
|
||||
boxShadow: 'var(--ol-shadow-md)',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
animation: 'ol-tooltip-in 0.12s ease-out both',
|
||||
};
|
||||
|
||||
const TOOLTIP_KEYFRAMES = `
|
||||
@keyframes ol-tooltip-in {
|
||||
from { opacity: 0; scale: .96; }
|
||||
to { opacity: 1; scale: 1; }
|
||||
}
|
||||
`;
|
||||
|
||||
if (typeof document !== 'undefined' && !document.getElementById('ol-tooltip-style')) {
|
||||
const tag = document.createElement('style');
|
||||
tag.id = 'ol-tooltip-style';
|
||||
tag.textContent = TOOLTIP_KEYFRAMES;
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// transform,不碰 blur),与设置弹窗、各市场弹窗保持一致。
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
children: ReactNode;
|
||||
@@ -16,7 +17,12 @@ interface ModalProps {
|
||||
}
|
||||
|
||||
export function Modal({ children, onClose, zIndex = 50, width = 'min(560px, 100%)' }: ModalProps) {
|
||||
return (
|
||||
// Portal 到 document.body:弹窗常从设置 / 市场等面板内部触发,而窗口 chrome
|
||||
// (WindowChrome)和页面容器带常驻 `will-change: transform`,会创建 containing
|
||||
// block —— 直接渲染的话 backdrop 的 `position: fixed` 会相对那个祖先而非视口定位,
|
||||
// 遮罩盖不住整窗(只压暗触发它的那块面板,比如 GitHub 登录浮在亮着的设置页上)。
|
||||
// Portal 出去后 fixed 相对视口,遮罩铺满全局。与 Tooltip / SelectLite 同款做法。
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
@@ -46,6 +52,7 @@ export function Modal({ children, onClose, zIndex = 50, width = 'min(560px, 100%
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -38,16 +38,20 @@ export const en: typeof zhCN = {
|
||||
translating: 'Translating',
|
||||
},
|
||||
qa: {
|
||||
title: 'Ask',
|
||||
headerHint: 'Ask anytime',
|
||||
thinking: 'Thinking…',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
errorRetry: 'Retry',
|
||||
errorRetryHint: 'Press {{recordHotkey}} again to retry.',
|
||||
errorRetryHint: 'Please try again.',
|
||||
pinTooltip: 'Pin (stay open)',
|
||||
unpinTooltip: 'Unpin',
|
||||
closeTooltip: 'Close',
|
||||
micLabel: 'Ask by voice',
|
||||
micStop: 'Stop recording',
|
||||
selectionPreview: 'From selected text:',
|
||||
emptyTitle: 'Press {{recordHotkey}} to ask',
|
||||
emptyDesc: 'Select text in any app, press {{recordHotkey}} once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.',
|
||||
emptyTitle: 'How can I help?',
|
||||
emptyDesc: 'Select any text to ask about it, or just type your question below. Answers appear here — ask as many follow-ups as you like.',
|
||||
recordingHint: 'Recording… press {{recordHotkey}} again to submit',
|
||||
mobileRecordLabel: 'record button',
|
||||
mobileRecordStart: 'Start recording',
|
||||
@@ -58,15 +62,21 @@ export const en: typeof zhCN = {
|
||||
statusRecording: 'Recording',
|
||||
statusThinking: 'Thinking',
|
||||
statusError: 'Error',
|
||||
jumpToLatest: 'Jump to latest',
|
||||
},
|
||||
lessComputer: {
|
||||
title: 'Less Computer',
|
||||
you: 'You',
|
||||
working: 'Operating…',
|
||||
tool: 'Used {{name}}',
|
||||
compaction: 'Context compacted',
|
||||
done: 'Done',
|
||||
cost: '${{cost}}',
|
||||
error: 'Failed. Try again.',
|
||||
closeTooltip: 'Close',
|
||||
jumpToLatest: 'Jump to latest',
|
||||
inputPlaceholder: 'Type a command, Enter to send',
|
||||
send: 'Send',
|
||||
approvalTitle: 'Run blocked command?',
|
||||
approvalRerunWarning: 'Note: approving re-runs on an already-modified workspace and may have side effects on non-idempotent operations.',
|
||||
approve: 'Approve',
|
||||
@@ -82,6 +92,11 @@ export const en: typeof zhCN = {
|
||||
marketplace: 'Marketplace',
|
||||
translation: 'Translation',
|
||||
selectionAsk: 'Ask',
|
||||
polishMode: 'Polish mode',
|
||||
group: {
|
||||
style: 'Style',
|
||||
tools: 'Tools',
|
||||
},
|
||||
localAsr: 'Models',
|
||||
more: 'More',
|
||||
},
|
||||
@@ -188,6 +203,15 @@ export const en: typeof zhCN = {
|
||||
shortcutHint: 'Start / Stop',
|
||||
betaTag: 'BETA',
|
||||
betaNote: 'All data stays on this device.',
|
||||
navHint: {
|
||||
overview: 'Status overview: usage stats, provider & permission health',
|
||||
history: 'Dictation history: search, replay and copy past transcripts',
|
||||
vocab: 'Vocabulary: custom hotwords for better proper-noun accuracy',
|
||||
style: 'Polish styles: manage output styles and custom prompts',
|
||||
translation: 'Translation: hold Shift while speaking to insert in a target language',
|
||||
selectionAsk: 'Selection ask: select text, then ask about it by voice',
|
||||
settings: 'Preferences: shortcuts, providers, privacy and updates',
|
||||
},
|
||||
footer: {
|
||||
account: 'Account',
|
||||
feedback: 'Feedback',
|
||||
@@ -280,6 +304,8 @@ export const en: typeof zhCN = {
|
||||
metricTotal: 'Total records',
|
||||
metricTotalTrend: 'Local archive (max 200)',
|
||||
weekTitle: 'Last 7 days',
|
||||
activityTitle: 'Annual activity',
|
||||
activityCount: '{{count}} dictation(s)',
|
||||
weekUnit: 'count / day',
|
||||
recentTitle: 'Recent transcripts',
|
||||
recentAll: 'View all →',
|
||||
@@ -982,6 +1008,7 @@ export const en: typeof zhCN = {
|
||||
theme: {
|
||||
title: 'Appearance',
|
||||
label: 'Theme',
|
||||
activityHeatmapLabel: 'Show annual activity heatmap on Overview',
|
||||
system: 'Follow system',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
|
||||
@@ -40,16 +40,20 @@ export const ja: typeof zhCN = {
|
||||
translating: '翻訳中',
|
||||
},
|
||||
qa: {
|
||||
title: '質問',
|
||||
headerHint: 'いつでも質問',
|
||||
thinking: '思考中…',
|
||||
error: 'エラーが発生しました。後でもう一度お試しください。',
|
||||
errorRetry: '再試行',
|
||||
errorRetryHint: '再度 {{recordHotkey}} を押して質問し直してください。',
|
||||
errorRetryHint: 'もう一度お試しください。',
|
||||
pinTooltip: 'ピン留め(自動で閉じない)',
|
||||
unpinTooltip: 'ピン留めを解除',
|
||||
closeTooltip: '閉じる',
|
||||
micLabel: '音声で質問',
|
||||
micStop: '録音を終了',
|
||||
selectionPreview: '選択テキスト:',
|
||||
emptyTitle: '{{recordHotkey}} を押して質問を開始',
|
||||
emptyDesc: '任意のアプリでテキストを選択した後、{{recordHotkey}} を 1 回押して録音を開始し、もう 1 回押して送信します。回答はここに表示され、続けて追加質問が可能です。',
|
||||
emptyTitle: 'ご用件は?',
|
||||
emptyDesc: 'テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。',
|
||||
recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します',
|
||||
mobileRecordLabel: '録音ボタン',
|
||||
mobileRecordStart: '録音を開始',
|
||||
@@ -60,15 +64,21 @@ export const ja: typeof zhCN = {
|
||||
statusRecording: '録音中',
|
||||
statusThinking: '思考中',
|
||||
statusError: 'エラー',
|
||||
jumpToLatest: '最新へ移動',
|
||||
},
|
||||
lessComputer: {
|
||||
title: 'Less Computer',
|
||||
you: 'あなた',
|
||||
working: '操作中…',
|
||||
tool: '{{name}} を使用',
|
||||
compaction: 'コンテキストを圧縮しました',
|
||||
done: '完了',
|
||||
cost: '${{cost}}',
|
||||
error: '失敗しました。再試行してください。',
|
||||
closeTooltip: '閉じる',
|
||||
jumpToLatest: '最新へ移動',
|
||||
inputPlaceholder: '指示を入力、Enter で送信',
|
||||
send: '送信',
|
||||
approvalTitle: 'ブロックされたコマンドを実行?',
|
||||
approvalRerunWarning: '注意:承認すると、すでに変更されたワークスペース上で再実行され、冪等でない操作に副作用が生じる可能性があります。',
|
||||
approve: '許可',
|
||||
@@ -84,6 +94,11 @@ export const ja: typeof zhCN = {
|
||||
marketplace: 'マーケット',
|
||||
translation: '翻訳',
|
||||
selectionAsk: '選択追問',
|
||||
polishMode: '推敲モード',
|
||||
group: {
|
||||
style: 'スタイル',
|
||||
tools: 'ツール',
|
||||
},
|
||||
localAsr: 'モデル設定',
|
||||
more: 'その他',
|
||||
},
|
||||
@@ -190,6 +205,15 @@ export const ja: typeof zhCN = {
|
||||
shortcutHint: '開始 / 停止',
|
||||
betaTag: 'BETA',
|
||||
betaNote: 'すべてのデータはローカルにのみ保存されます。',
|
||||
navHint: {
|
||||
overview: 'ステータス概要:使用状況・プロバイダー・権限の状態',
|
||||
history: '入力履歴:過去の書き起こしを検索・再生・コピー',
|
||||
vocab: '語彙:固有名詞の認識精度を上げるカスタムホットワード',
|
||||
style: 'スタイル:出力スタイルとカスタムプロンプトを管理',
|
||||
translation: '翻訳:Shift を押しながら話すと目標言語で挿入',
|
||||
selectionAsk: '選択質問:テキストを選択して音声で質問',
|
||||
settings: '環境設定:ショートカット・プロバイダー・プライバシー・更新',
|
||||
},
|
||||
footer: {
|
||||
account: 'アカウント',
|
||||
feedback: 'フィードバック',
|
||||
@@ -282,6 +306,8 @@ export const ja: typeof zhCN = {
|
||||
metricTotal: '累計記録',
|
||||
metricTotalTrend: 'ローカル保存(上限 200)',
|
||||
weekTitle: '直近 7 日',
|
||||
activityTitle: '年間アクティビティ',
|
||||
activityCount: '{{count}} 回の入力',
|
||||
weekUnit: '件 / 日',
|
||||
recentTitle: '最近の認識',
|
||||
recentAll: 'すべて表示 →',
|
||||
@@ -950,6 +976,7 @@ export const ja: typeof zhCN = {
|
||||
theme: {
|
||||
title: '外観',
|
||||
label: 'テーマ',
|
||||
activityHeatmapLabel: '概要ページに年間アクティビティを表示',
|
||||
system: 'システムに従う',
|
||||
light: 'ライト',
|
||||
dark: 'ダーク',
|
||||
|
||||
@@ -40,16 +40,20 @@ export const ko: typeof zhCN = {
|
||||
translating: '번역 중',
|
||||
},
|
||||
qa: {
|
||||
title: '질문',
|
||||
headerHint: '언제든 질문하세요',
|
||||
thinking: '생각 중…',
|
||||
error: '오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
errorRetry: '재시도',
|
||||
errorRetryHint: '{{recordHotkey}} 를 다시 눌러 질문하세요.',
|
||||
errorRetryHint: '다시 시도해 주세요.',
|
||||
pinTooltip: '고정(자동으로 닫히지 않음)',
|
||||
unpinTooltip: '고정 해제',
|
||||
closeTooltip: '닫기',
|
||||
micLabel: '음성으로 질문',
|
||||
micStop: '녹음 종료',
|
||||
selectionPreview: '선택된 텍스트 기반:',
|
||||
emptyTitle: '{{recordHotkey}} 를 눌러 질문 시작',
|
||||
emptyDesc: '아무 앱에서 텍스트를 선택한 후 {{recordHotkey}} 를 한 번 눌러 녹음을 시작하고, 다시 한 번 눌러 종료 후 제출합니다. 답변이 여기에 표시되며 연속해서 후속 질문이 가능합니다.',
|
||||
emptyTitle: '무엇을 도와드릴까요?',
|
||||
emptyDesc: '텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.',
|
||||
recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문',
|
||||
mobileRecordLabel: '녹음 버튼',
|
||||
mobileRecordStart: '녹음 시작',
|
||||
@@ -60,15 +64,21 @@ export const ko: typeof zhCN = {
|
||||
statusRecording: '녹음 중',
|
||||
statusThinking: '생각 중',
|
||||
statusError: '오류',
|
||||
jumpToLatest: '최신으로 이동',
|
||||
},
|
||||
lessComputer: {
|
||||
title: 'Less Computer',
|
||||
you: '나',
|
||||
working: '조작 중…',
|
||||
tool: '{{name}} 사용',
|
||||
compaction: '컨텍스트가 압축되었습니다',
|
||||
done: '완료',
|
||||
cost: '${{cost}}',
|
||||
error: '실패했습니다. 다시 시도하세요.',
|
||||
closeTooltip: '닫기',
|
||||
jumpToLatest: '최신으로 이동',
|
||||
inputPlaceholder: '명령을 입력하고 Enter로 전송',
|
||||
send: '전송',
|
||||
approvalTitle: '차단된 명령을 실행할까요?',
|
||||
approvalRerunWarning: '주의: 승인하면 이미 수정된 작업 공간에서 다시 실행되어 멱등하지 않은 작업에 부작용이 생길 수 있습니다.',
|
||||
approve: '허용',
|
||||
@@ -84,6 +94,11 @@ export const ko: typeof zhCN = {
|
||||
marketplace: '마켓',
|
||||
translation: '번역',
|
||||
selectionAsk: '선택 질문',
|
||||
polishMode: '다듬기 모드',
|
||||
group: {
|
||||
style: '스타일',
|
||||
tools: '도구',
|
||||
},
|
||||
localAsr: '모델 설정',
|
||||
more: '더보기',
|
||||
},
|
||||
@@ -190,6 +205,15 @@ export const ko: typeof zhCN = {
|
||||
shortcutHint: '시작 / 정지',
|
||||
betaTag: 'BETA',
|
||||
betaNote: '모든 데이터는 로컬에만 저장됩니다.',
|
||||
navHint: {
|
||||
overview: '상태 개요: 사용량 통계, 제공자 및 권한 상태',
|
||||
history: '받아쓰기 기록: 과거 전사 검색·재생·복사',
|
||||
vocab: '어휘: 고유명사 인식률을 높이는 사용자 지정 핫워드',
|
||||
style: '스타일: 출력 스타일과 사용자 지정 프롬프트 관리',
|
||||
translation: '번역: Shift를 누른 채 말하면 대상 언어로 삽입',
|
||||
selectionAsk: '선택 질문: 텍스트를 선택한 뒤 음성으로 질문',
|
||||
settings: '환경설정: 단축키, 제공자, 개인정보 및 업데이트',
|
||||
},
|
||||
footer: {
|
||||
account: '계정',
|
||||
feedback: '피드백',
|
||||
@@ -282,6 +306,8 @@ export const ko: typeof zhCN = {
|
||||
metricTotal: '누적 기록',
|
||||
metricTotalTrend: '로컬 보관(상한 200)',
|
||||
weekTitle: '최근 7일',
|
||||
activityTitle: '연간 활동',
|
||||
activityCount: '{{count}}회 받아쓰기',
|
||||
weekUnit: '건/일',
|
||||
recentTitle: '최근 인식',
|
||||
recentAll: '전체 보기 →',
|
||||
@@ -950,6 +976,7 @@ export const ko: typeof zhCN = {
|
||||
theme: {
|
||||
title: '모양',
|
||||
label: '테마',
|
||||
activityHeatmapLabel: '개요 페이지에 연간 활동 표시',
|
||||
system: '시스템 따르기',
|
||||
light: '라이트',
|
||||
dark: '다크',
|
||||
|
||||
@@ -36,16 +36,20 @@ export const zhCN = {
|
||||
translating: '正在翻译',
|
||||
},
|
||||
qa: {
|
||||
title: '划词追问',
|
||||
headerHint: '随时提问',
|
||||
thinking: '思考中…',
|
||||
error: '出错了,请稍后再试。',
|
||||
errorRetry: '重试',
|
||||
errorRetryHint: '再按 {{recordHotkey}} 重新提问。',
|
||||
errorRetryHint: '请再试一次。',
|
||||
pinTooltip: '固定(不自动关闭)',
|
||||
unpinTooltip: '取消固定',
|
||||
closeTooltip: '关闭',
|
||||
micLabel: '语音提问',
|
||||
micStop: '结束录音',
|
||||
selectionPreview: '基于选中文本:',
|
||||
emptyTitle: '按 {{recordHotkey}} 开始提问',
|
||||
emptyDesc: '在任意 app 选中一段文字后,按一次 {{recordHotkey}} 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。',
|
||||
emptyTitle: '有什么可以帮你?',
|
||||
emptyDesc: '选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。',
|
||||
recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问',
|
||||
mobileRecordLabel: '录音按钮',
|
||||
mobileRecordStart: '开始录音',
|
||||
@@ -56,15 +60,21 @@ export const zhCN = {
|
||||
statusRecording: '录音中',
|
||||
statusThinking: '思考中',
|
||||
statusError: '出错了',
|
||||
jumpToLatest: '跳到最新',
|
||||
},
|
||||
lessComputer: {
|
||||
title: 'Less Computer',
|
||||
you: '你',
|
||||
working: '正在操控电脑…',
|
||||
tool: '调用了 {{name}}',
|
||||
compaction: '上下文已压缩',
|
||||
done: '完成',
|
||||
cost: '${{cost}}',
|
||||
error: '失败,请重试。',
|
||||
closeTooltip: '关闭',
|
||||
jumpToLatest: '跳到最新',
|
||||
inputPlaceholder: '输入指令,Enter 发送',
|
||||
send: '发送',
|
||||
approvalTitle: '执行被拦截的命令?',
|
||||
approvalRerunWarning: '注意:批准后将在已被修改的工作区上重新运行,可能对不可重入操作产生副作用',
|
||||
approve: '允许',
|
||||
@@ -80,6 +90,11 @@ export const zhCN = {
|
||||
marketplace: '风格市场',
|
||||
translation: '翻译',
|
||||
selectionAsk: '划词追问',
|
||||
polishMode: '润色模式',
|
||||
group: {
|
||||
style: '风格',
|
||||
tools: '工具',
|
||||
},
|
||||
localAsr: '模型设置',
|
||||
more: '更多',
|
||||
},
|
||||
@@ -186,6 +201,15 @@ export const zhCN = {
|
||||
shortcutHint: '开始 / 停止',
|
||||
betaTag: 'BETA',
|
||||
betaNote: '所有数据都只保存在本机。',
|
||||
navHint: {
|
||||
overview: '状态总览:用量统计、提供商与权限健康检查',
|
||||
history: '听写历史:搜索、回放与复制过往转写',
|
||||
vocab: '词汇表:自定义热词,提升专有名词识别率',
|
||||
style: '润色风格:管理输出风格与自定义提示词',
|
||||
translation: '翻译:按住 Shift 说话,译成目标语言插入',
|
||||
selectionAsk: '划词追问:选中文字后语音提问',
|
||||
settings: '偏好设置:快捷键、提供商、隐私与更新',
|
||||
},
|
||||
footer: {
|
||||
account: '账户',
|
||||
feedback: '反馈',
|
||||
@@ -278,6 +302,8 @@ export const zhCN = {
|
||||
metricTotal: '累计记录',
|
||||
metricTotalTrend: '本机存档 (上限 200)',
|
||||
weekTitle: '近 7 天',
|
||||
activityTitle: '年度活动',
|
||||
activityCount: '{{count}} 次听写',
|
||||
weekUnit: '条数 / 天',
|
||||
recentTitle: '最近识别',
|
||||
recentAll: '全部记录 →',
|
||||
@@ -758,7 +784,7 @@ export const zhCN = {
|
||||
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
|
||||
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
|
||||
asrLocalQwen3: '本地 Qwen3-ASR',
|
||||
asrAppleSpeech: 'Apple 语音 (macOS)',
|
||||
asrAppleSpeech: 'Apple 语音(macOS)',
|
||||
},
|
||||
volcengineAppKeyLabel: 'APP ID',
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
@@ -980,6 +1006,7 @@ export const zhCN = {
|
||||
theme: {
|
||||
title: '外观',
|
||||
label: '主题',
|
||||
activityHeatmapLabel: '概览页显示年度活动热力图',
|
||||
system: '跟随系统',
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
@@ -1168,7 +1195,7 @@ export const zhCN = {
|
||||
revealDir: '打开目录',
|
||||
deleteConfirm: '确定删除 {{name}} 的本地模型文件吗?此操作无法撤销。',
|
||||
appleSpeechTitle: 'Apple 语音识别(macOS)',
|
||||
appleSpeechDesc: '使用 macOS 系统自带的语音识别在本地把语音转成文字:无需下载模型、无需 API Key、无需联网。云端 ASR 网络不稳时的零凭据本地兜底。首次使用会弹出系统语音识别授权。',
|
||||
appleSpeechDesc: 'macOS 系统自带的语音识别,在本机把语音转成文字:不用下载模型、不用填 API Key,主流语言可完全离线、音频不出本机。适合作云端 ASR 网络不稳时的本地兜底;首次使用会弹出系统语音识别授权。',
|
||||
appleSpeechUse: '使用 Apple 语音',
|
||||
qwenTitle: 'Qwen3-ASR 模型管理',
|
||||
qwenExperimentalBadge: '实验性',
|
||||
|
||||
@@ -38,16 +38,20 @@ export const zhTW: typeof zhCN = {
|
||||
translating: '正在翻譯',
|
||||
},
|
||||
qa: {
|
||||
title: '劃詞追問',
|
||||
headerHint: '隨時提問',
|
||||
thinking: '思考中…',
|
||||
error: '出錯了,請稍後再試。',
|
||||
errorRetry: '重試',
|
||||
errorRetryHint: '再按 {{recordHotkey}} 重新提問。',
|
||||
errorRetryHint: '請再試一次。',
|
||||
pinTooltip: '固定(不自動關閉)',
|
||||
unpinTooltip: '取消固定',
|
||||
closeTooltip: '關閉',
|
||||
micLabel: '語音提問',
|
||||
micStop: '結束錄音',
|
||||
selectionPreview: '基於選中文本:',
|
||||
emptyTitle: '按 {{recordHotkey}} 開始提問',
|
||||
emptyDesc: '在任意 app 選中一段文字後,按一次 {{recordHotkey}} 開始錄音,再按一次結束並提交。回答會顯示在這裏,可以連續多輪追問。',
|
||||
emptyTitle: '有什麼可以幫你?',
|
||||
emptyDesc: '選中任意文字後開始追問,或直接在下方輸入問題。回答會顯示在這裏,可以連續多輪。',
|
||||
recordingHint: '錄音中…再按一次 {{recordHotkey}} 結束並提問',
|
||||
mobileRecordLabel: '錄音按鈕',
|
||||
mobileRecordStart: '開始錄音',
|
||||
@@ -58,15 +62,21 @@ export const zhTW: typeof zhCN = {
|
||||
statusRecording: '錄音中',
|
||||
statusThinking: '思考中',
|
||||
statusError: '出錯了',
|
||||
jumpToLatest: '跳到最新',
|
||||
},
|
||||
lessComputer: {
|
||||
title: 'Less Computer',
|
||||
you: '你',
|
||||
working: '正在操控電腦…',
|
||||
tool: '呼叫了 {{name}}',
|
||||
compaction: '上下文已壓縮',
|
||||
done: '完成',
|
||||
cost: '${{cost}}',
|
||||
error: '失敗,請重試。',
|
||||
closeTooltip: '關閉',
|
||||
jumpToLatest: '跳到最新',
|
||||
inputPlaceholder: '輸入指令,Enter 傳送',
|
||||
send: '傳送',
|
||||
approvalTitle: '執行被攔截的指令?',
|
||||
approvalRerunWarning: '注意:批准後將在已被修改的工作區上重新執行,可能對不可重入操作產生副作用',
|
||||
approve: '允許',
|
||||
@@ -82,6 +92,11 @@ export const zhTW: typeof zhCN = {
|
||||
marketplace: '風格市場',
|
||||
translation: '翻譯',
|
||||
selectionAsk: '劃詞追問',
|
||||
polishMode: '潤色模式',
|
||||
group: {
|
||||
style: '風格',
|
||||
tools: '工具',
|
||||
},
|
||||
localAsr: '模型設置',
|
||||
more: '更多',
|
||||
},
|
||||
@@ -188,6 +203,15 @@ export const zhTW: typeof zhCN = {
|
||||
shortcutHint: '開始 / 停止',
|
||||
betaTag: 'BETA',
|
||||
betaNote: '所有數據都只保存在本機。',
|
||||
navHint: {
|
||||
overview: '狀態總覽:用量統計、提供商與權限健康檢查',
|
||||
history: '聽寫歷史:搜尋、回放與複製過往轉寫',
|
||||
vocab: '詞彙表:自訂熱詞,提升專有名詞辨識率',
|
||||
style: '潤色風格:管理輸出風格與自訂提示詞',
|
||||
translation: '翻譯:按住 Shift 說話,譯成目標語言插入',
|
||||
selectionAsk: '劃詞追問:選取文字後語音提問',
|
||||
settings: '偏好設定:快捷鍵、提供商、隱私與更新',
|
||||
},
|
||||
footer: {
|
||||
account: '賬戶',
|
||||
feedback: '反饋',
|
||||
@@ -280,6 +304,8 @@ export const zhTW: typeof zhCN = {
|
||||
metricTotal: '累計記錄',
|
||||
metricTotalTrend: '本機存檔 (上限 200)',
|
||||
weekTitle: '近 7 天',
|
||||
activityTitle: '年度活動',
|
||||
activityCount: '{{count}} 次聽寫',
|
||||
weekUnit: '條數 / 天',
|
||||
recentTitle: '最近識別',
|
||||
recentAll: '全部記錄 →',
|
||||
@@ -760,7 +786,7 @@ export const zhTW: typeof zhCN = {
|
||||
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
|
||||
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
|
||||
asrLocalQwen3: '本地 Qwen3-ASR',
|
||||
asrAppleSpeech: 'Apple 語音 (macOS)',
|
||||
asrAppleSpeech: 'Apple 語音(macOS)',
|
||||
},
|
||||
volcengineAppKeyLabel: 'APP ID',
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
@@ -948,6 +974,7 @@ export const zhTW: typeof zhCN = {
|
||||
theme: {
|
||||
title: '外觀',
|
||||
label: '主題',
|
||||
activityHeatmapLabel: '概覽頁顯示年度活動熱力圖',
|
||||
system: '跟隨系統',
|
||||
light: '淺色',
|
||||
dark: '深色',
|
||||
@@ -1136,7 +1163,7 @@ export const zhTW: typeof zhCN = {
|
||||
revealDir: '開啟目錄',
|
||||
deleteConfirm: '確定刪除 {{name}} 的本地模型檔案嗎?此操作無法復原。',
|
||||
appleSpeechTitle: 'Apple 語音辨識(macOS)',
|
||||
appleSpeechDesc: '使用 macOS 系統內建的語音辨識在本地將語音轉成文字:無需下載模型、無需 API Key、無需連網。雲端 ASR 網路不穩時的零憑證本地後備。首次使用會跳出系統語音辨識授權。',
|
||||
appleSpeechDesc: 'macOS 系統內建的語音辨識,在本機把語音轉成文字:不用下載模型、不用填 API Key,主流語言可完全離線、音訊不出本機。適合作雲端 ASR 網路不穩時的本地後備;首次使用會跳出系統語音辨識授權。',
|
||||
appleSpeechUse: '使用 Apple 語音',
|
||||
qwenTitle: 'Qwen3-ASR 模型管理',
|
||||
qwenExperimentalBadge: '實驗性',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import {
|
||||
audioContextActionForState,
|
||||
cueActionAfterResume,
|
||||
cueTotalDurationMs,
|
||||
recordStartCueTones,
|
||||
shouldPlayDeferredCue,
|
||||
@@ -142,5 +143,40 @@ function assertEqual<T>(actual: T, expected: T, name: string) {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// 「用久了没声音」修复的回归钉子:resume() 之后的处置决策(cueActionAfterResume)。
|
||||
// resume 成功、ctx 真在跑、且仍该播 → 排期发声。
|
||||
assertEqual(
|
||||
cueActionAfterResume({ runningAfterResume: true, shouldPlay: true, allowRecreate: true }),
|
||||
'schedule',
|
||||
'running-after-resume and should-play schedules the cue',
|
||||
);
|
||||
// 被新一轮播放接管 / 真迟到(shouldPlay=false)→ 丢弃,且不重建(让最新那次处理)。
|
||||
assertEqual(
|
||||
cueActionAfterResume({ runningAfterResume: true, shouldPlay: false, allowRecreate: true }),
|
||||
'drop',
|
||||
'superseded or late cue is dropped even when the context is running',
|
||||
);
|
||||
// 核心修复:resume 被拒、或名义 resolve 但 ctx 仍非 running(runningAfterResume=false)——
|
||||
// 只要还该播且可重建,就丢弃坏死 ctx 重试,绝不静默放弃 / 不在冻结时钟上排期。
|
||||
assertEqual(
|
||||
cueActionAfterResume({ runningAfterResume: false, shouldPlay: true, allowRecreate: true }),
|
||||
'recreate-retry',
|
||||
'a context that will not wake recreates instead of going permanently silent',
|
||||
);
|
||||
// 重试一次后仍唤不醒(allowRecreate=false)→ 放弃,避免坏死 ctx 上无限递归。
|
||||
assertEqual(
|
||||
cueActionAfterResume({ runningAfterResume: false, shouldPlay: true, allowRecreate: false }),
|
||||
'drop',
|
||||
'second attempt gives up to avoid an infinite recreate loop',
|
||||
);
|
||||
// 本就不该播时,即使唤不醒也不浪费一次重建。
|
||||
assertEqual(
|
||||
cueActionAfterResume({ runningAfterResume: false, shouldPlay: false, allowRecreate: true }),
|
||||
'drop',
|
||||
'no recreate is spent when the cue should not play anyway',
|
||||
);
|
||||
}
|
||||
|
||||
// 静默成功难以与「没跑」区分;直接 tsx 跑时给个明确通过信号。
|
||||
console.log('[audioCue.test] all assertions passed');
|
||||
@@ -101,6 +101,32 @@ export function audioContextActionForState(state: string): AudioContextAction {
|
||||
return 'resume';
|
||||
}
|
||||
|
||||
/** resume() 完成后对提示音的处置。 */
|
||||
export type CueAction = 'schedule' | 'recreate-retry' | 'drop';
|
||||
|
||||
/**
|
||||
* resume() 结束后如何处置这次提示音。纯函数,便于单测,是「用久了没声音」修复的判定核心。
|
||||
*
|
||||
* 关键修复点:长时间使用中,本地 ASR / 录音反复抢占系统音频会话后,WKWebView 的共享
|
||||
* AudioContext 常被推进 'suspended' 或 WebKit 非标准的 'interrupted',此时 resume() 会被拒、
|
||||
* 或名义 resolve 了但 ctx 仍非 'running'(currentTime 冻结)。旧逻辑要么把 resume 失败静默
|
||||
* 吞掉、要么在冻结的时钟上排期 —— 两条路都让提示音永久静默直到进程重启。这里把这两种
|
||||
* 「唤不醒」的退化态统一判为「丢弃坏死 ctx、用全新 ctx 重试一次」,让共享 ctx 自愈。
|
||||
* ('closed' 早在 getContext 就重建掉了,不会走到这里。)
|
||||
* - resume 成功且 ctx 真在跑:还该播 → 'schedule',否则 'drop'(被新一轮接管 / 真迟到)。
|
||||
* - 否则(被拒 / resolve 了仍非 running):允许重建 → 'recreate-retry';不允许(已重试过一次)
|
||||
* → 'drop',避免在坏死 ctx 上无限递归。
|
||||
*/
|
||||
export function cueActionAfterResume(params: {
|
||||
runningAfterResume: boolean;
|
||||
shouldPlay: boolean;
|
||||
allowRecreate: boolean;
|
||||
}): CueAction {
|
||||
if (!params.shouldPlay) return 'drop';
|
||||
if (params.runningAfterResume) return 'schedule';
|
||||
return params.allowRecreate ? 'recreate-retry' : 'drop';
|
||||
}
|
||||
|
||||
function resolveAudioContextCtor(): AudioContextCtor | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// window.AudioContext 来自全局声明;webkit 前缀单独用结构化类型拿,避免 any。
|
||||
@@ -128,6 +154,20 @@ function getContext(): AudioContext | null {
|
||||
return sharedCtx;
|
||||
}
|
||||
|
||||
// 丢弃一个唤不醒 / 坏死的 AudioContext:清空模块单例(下次 getContext 会重建全新一份)与挂在
|
||||
// 其上的 activeVoices,并尽力 close 释放底层音频资源。已 closed / close 失败都忽略。
|
||||
function discardContext(ctx: AudioContext): void {
|
||||
if (sharedCtx === ctx) {
|
||||
sharedCtx = null;
|
||||
}
|
||||
activeVoices = [];
|
||||
try {
|
||||
void ctx.close().catch(() => undefined);
|
||||
} catch {
|
||||
// 已 closed,或实现未返回 Promise,忽略。
|
||||
}
|
||||
}
|
||||
|
||||
// 停掉当前正在发声的节点(不动 playSeq / stopSeq —— 仅做去叠音 / 收尾)。
|
||||
function stopVoices(): void {
|
||||
const ctx = sharedCtx;
|
||||
@@ -210,35 +250,52 @@ export function primeAudioCue(): void {
|
||||
|
||||
/** 播放「开始录音」提示音。无 Web Audio 或被挂起且无法恢复时静默降级。 */
|
||||
export function playRecordStartCue(): void {
|
||||
playRecordStartCueOnce(true);
|
||||
}
|
||||
|
||||
// allowRecreate 把「丢弃坏死 ctx 并重试」限制为最多一次,避免在唤不醒的 ctx 上无限递归。
|
||||
function playRecordStartCueOnce(allowRecreate: boolean): void {
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
|
||||
// WKWebView / WebView2 的 AudioContext 常处于 suspended(或被音频会话抢占后的非标准
|
||||
// interrupted):必须先 resume 再排期,不能在 resume 未完成时就用冻结的 currentTime 排
|
||||
// 节点。resume() 失败也不抛(无声降级)。closed 的情况已在 getContext 重建掉了。
|
||||
if (audioContextActionForState(ctx.state) === 'resume') {
|
||||
const myPlay = ++playSeq;
|
||||
const stopAtRequest = stopSeq;
|
||||
const requestedAt = nowMs();
|
||||
ctx
|
||||
.resume()
|
||||
.then(() => {
|
||||
// 被更新一轮播放接管就让位;期间录音已停且 resume 真迟到才丢弃;
|
||||
// 否则照常补响——快速点一下录音(resume 没跑完就已结束)也该有提示音。
|
||||
if (
|
||||
shouldPlayDeferredCue({
|
||||
superseded: myPlay !== playSeq,
|
||||
stoppedWhileWaiting: stopSeq !== stopAtRequest,
|
||||
elapsedMs: nowMs() - requestedAt,
|
||||
lateThresholdMs: DEFERRED_CUE_LATE_THRESHOLD_MS,
|
||||
})
|
||||
) {
|
||||
scheduleCueVoices(ctx);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
// ctx 已 running 直接排期;closed 已在 getContext 重建。其余(suspended / WebKit 非标准
|
||||
// interrupted / 未知态)必须先 resume 再排期,不能在 resume 未完成时就用冻结的 currentTime。
|
||||
if (audioContextActionForState(ctx.state) !== 'resume') {
|
||||
scheduleCueVoices(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleCueVoices(ctx);
|
||||
const myPlay = ++playSeq;
|
||||
const stopAtRequest = stopSeq;
|
||||
const requestedAt = nowMs();
|
||||
|
||||
// resume 完成(成功或被拒)后统一决策:排期 / 丢弃重建重试 / 放弃。
|
||||
const settle = (runningAfterResume: boolean): void => {
|
||||
const action = cueActionAfterResume({
|
||||
runningAfterResume,
|
||||
// 被更新一轮播放接管就让位;期间录音已停且 resume 真迟到才丢弃;否则照常补响——
|
||||
// 快速点一下录音(resume 没跑完就已结束)也该有提示音。
|
||||
shouldPlay: shouldPlayDeferredCue({
|
||||
superseded: myPlay !== playSeq,
|
||||
stoppedWhileWaiting: stopSeq !== stopAtRequest,
|
||||
elapsedMs: nowMs() - requestedAt,
|
||||
lateThresholdMs: DEFERRED_CUE_LATE_THRESHOLD_MS,
|
||||
}),
|
||||
allowRecreate,
|
||||
});
|
||||
if (action === 'schedule') {
|
||||
scheduleCueVoices(ctx);
|
||||
} else if (action === 'recreate-retry') {
|
||||
// resume 被拒、或 resolve 了但 ctx 仍非 running(被音频会话抢占后卡死)——「用久了没
|
||||
// 声音」的根因。丢弃这个唤不醒的 ctx,用全新 ctx 重试一次,让共享 ctx 自愈。
|
||||
discardContext(ctx);
|
||||
playRecordStartCueOnce(false);
|
||||
}
|
||||
// 'drop':被接管 / 真迟到 / 重试后仍唤不醒——静默放弃。
|
||||
};
|
||||
|
||||
ctx
|
||||
.resume()
|
||||
.then(() => settle(ctx.state === 'running'))
|
||||
.catch(() => settle(false));
|
||||
}
|
||||
@@ -11,41 +11,43 @@ function assertEqual<T>(actual: T, expected: T, name: string) {
|
||||
}
|
||||
|
||||
const winMetrics = getCapsulePillMetrics('win');
|
||||
assertEqual(winMetrics.width, 196, 'windows capsule widens pill');
|
||||
assertEqual(winMetrics.height, 52, 'windows capsule increases pill height');
|
||||
assertEqual(winMetrics.textWidth, 104, 'windows capsule keeps side controls clear');
|
||||
assertEqual(winMetrics.boxSizing, 'border-box', 'windows capsule pill width is an outer border-box metric');
|
||||
assertEqual(winMetrics.width, 460, 'windows voice orb stage uses the demo-scale width');
|
||||
assertEqual(winMetrics.height, 180, 'windows voice orb stage uses the demo-scale height');
|
||||
assertEqual(winMetrics.textWidth, 400, 'windows voice orb text stays inside the stage');
|
||||
assertEqual(winMetrics.boxSizing, 'border-box', 'windows voice orb stage width is an outer border-box metric');
|
||||
|
||||
const winHost = getCapsuleHostMetrics('win', false);
|
||||
assertEqual(winHost.width, 220, 'windows capsule host keeps the current outer hitbox width');
|
||||
assertEqual(winHost.height, 84, 'windows capsule host keeps regular height');
|
||||
assertEqual(winHost.horizontalInset, 12, 'windows capsule host keeps symmetric shadow insets');
|
||||
assertEqual(winHost.boxSizing, 'border-box', 'windows capsule host inset is reserved inside the native width');
|
||||
assertEqual(
|
||||
winHost.width,
|
||||
winMetrics.width + winHost.horizontalInset * 2,
|
||||
'windows capsule host width derives from pill width plus symmetric side insets',
|
||||
);
|
||||
assertEqual(winHost.width, 460, 'windows voice orb host matches stage width');
|
||||
assertEqual(winHost.height, 180, 'windows voice orb host matches stage height');
|
||||
assertEqual(winHost.horizontalInset, 0, 'windows voice orb host has no side button inset');
|
||||
assertEqual(winHost.boxSizing, 'border-box', 'windows voice orb host keeps border-box sizing');
|
||||
assertEqual(
|
||||
winHost.width - winHost.horizontalInset * 2,
|
||||
winMetrics.width,
|
||||
'windows capsule host keeps the visible pill width after reserving side insets',
|
||||
'windows voice orb host keeps the visible stage width after reserving side insets',
|
||||
);
|
||||
assertEqual(
|
||||
winHost.height - winHost.bottomInset,
|
||||
winMetrics.height,
|
||||
'windows voice orb host keeps the visible stage height after reserving bottom inset',
|
||||
);
|
||||
|
||||
const winHostWithTranslation = getCapsuleHostMetrics('win', true);
|
||||
assertEqual(winHostWithTranslation.width, 220, 'windows translation capsule keeps the same outer width');
|
||||
assertEqual(winHostWithTranslation.height, 118, 'windows translation capsule grows vertically only');
|
||||
assertEqual(winHostWithTranslation.horizontalInset, 12, 'windows translation capsule keeps symmetric side insets');
|
||||
assertEqual(winHostWithTranslation.boxSizing, 'border-box', 'windows translation host keeps the same inset-reserving box model');
|
||||
assertEqual(winHostWithTranslation.width, 460, 'windows translation voice orb keeps the same outer width');
|
||||
assertEqual(winHostWithTranslation.height, 180, 'windows translation voice orb keeps the same outer height');
|
||||
assertEqual(winHostWithTranslation.horizontalInset, 0, 'windows translation voice orb has no side button inset');
|
||||
assertEqual(winHostWithTranslation.boxSizing, 'border-box', 'windows translation host keeps border-box sizing');
|
||||
|
||||
const macMetrics = getCapsulePillMetrics('mac');
|
||||
assertEqual(macMetrics.width, 176, 'mac capsule keeps existing pill width');
|
||||
assertEqual(macMetrics.height, 42, 'mac capsule keeps existing pill height');
|
||||
assertEqual(macMetrics.textWidth, 84, 'mac capsule keeps existing text slot');
|
||||
assertEqual(macMetrics.boxSizing, 'border-box', 'mac capsule keeps the existing border-box pill model');
|
||||
assertEqual(macMetrics.width, 460, 'mac voice orb stage uses the demo-scale width');
|
||||
assertEqual(macMetrics.height, 180, 'mac voice orb stage uses the demo-scale height');
|
||||
assertEqual(macMetrics.textWidth, 400, 'mac voice orb text stays inside the stage');
|
||||
assertEqual(macMetrics.boxSizing, 'border-box', 'mac voice orb stage keeps border-box sizing');
|
||||
|
||||
const macHost = getCapsuleHostMetrics('mac', false);
|
||||
assertEqual(macHost.boxSizing, 'border-box', 'mac capsule host keeps the existing border-box box model');
|
||||
assertEqual(macHost.width, 460, 'mac voice orb host matches stage width');
|
||||
assertEqual(macHost.height, 180, 'mac voice orb host matches stage height');
|
||||
assertEqual(macHost.boxSizing, 'border-box', 'mac voice orb host keeps border-box sizing');
|
||||
|
||||
const winErrorLayout = getCapsuleMessageLayout('win', 'error');
|
||||
assertEqual(winErrorLayout.lineClamp, 2, 'windows error message allows two lines');
|
||||
|
||||
@@ -23,36 +23,32 @@ export interface CapsuleMessageLayout {
|
||||
lineClamp: number;
|
||||
}
|
||||
|
||||
export function getCapsulePillMetrics(os: OS): CapsulePillMetrics {
|
||||
if (os === 'win') {
|
||||
// Windows metrics 必须与 src-tauri/src/lib.rs 的原生命中框合同保持一致。
|
||||
return { width: 196, height: 52, textWidth: 104, boxSizing: 'border-box' };
|
||||
}
|
||||
// 纯光效舞台按 siri-glsl demo 的原始比例呈现:光条横贯 ~420px(demo 画布宽),
|
||||
// 舞台 460×180 给发光扩散留余量。与 src-tauri/src/lib.rs 的 capsule_window_bounds /
|
||||
// capsule_visual_height 保持一致。
|
||||
const VOICE_ORB_STAGE_WIDTH = 460;
|
||||
const VOICE_ORB_STAGE_HEIGHT = 180;
|
||||
const VOICE_ORB_TEXT_WIDTH = 400;
|
||||
|
||||
return { width: 176, height: 42, textWidth: 84, boxSizing: 'border-box' };
|
||||
export function getCapsulePillMetrics(os: OS): CapsulePillMetrics {
|
||||
void os;
|
||||
return {
|
||||
width: VOICE_ORB_STAGE_WIDTH,
|
||||
height: VOICE_ORB_STAGE_HEIGHT,
|
||||
textWidth: VOICE_ORB_TEXT_WIDTH,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
}
|
||||
|
||||
// macOS 走 1.2.11 calc 布局,不依赖 host metrics;Windows 端要更大的 host
|
||||
// 装下阴影 inset,仍用这一份。
|
||||
export function getCapsuleHostMetrics(
|
||||
os: OS,
|
||||
translationActive: boolean,
|
||||
): CapsuleHostMetrics {
|
||||
if (os === 'win') {
|
||||
const horizontalInset = 12;
|
||||
const pill = getCapsulePillMetrics(os);
|
||||
return {
|
||||
width: pill.width + horizontalInset * 2,
|
||||
height: translationActive ? 118 : 84,
|
||||
horizontalInset,
|
||||
bottomInset: 12,
|
||||
badgeGap: 8,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
}
|
||||
const stage = getCapsulePillMetrics(os);
|
||||
void translationActive;
|
||||
return {
|
||||
width: 176,
|
||||
height: 42,
|
||||
width: stage.width,
|
||||
height: stage.height,
|
||||
horizontalInset: 0,
|
||||
bottomInset: 0,
|
||||
badgeGap: 8,
|
||||
|
||||
@@ -217,7 +217,10 @@ export function shortcutFromLegacyTrigger(trigger: HotkeyTrigger): ShortcutBindi
|
||||
}
|
||||
|
||||
/** 把 ComboBinding 或 QaHotkeyBinding 格式化为可读标签,如 "⌘⇧D" / "Ctrl+Shift+D"。 */
|
||||
export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | ShortcutBinding): string {
|
||||
/** 组合键的逐键显示段(修饰键按固定顺序 + 主键),供键帽(Kbd)逐键渲染。 */
|
||||
export function formatComboParts(
|
||||
binding: ComboBinding | QaHotkeyBinding | ShortcutBinding,
|
||||
): string[] {
|
||||
const parts: string[] = [];
|
||||
const platform = currentPlatform();
|
||||
|
||||
@@ -234,7 +237,11 @@ export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | Short
|
||||
}
|
||||
|
||||
parts.push(formatPrimary(binding.primary));
|
||||
return parts.join(platform.isMac ? '' : '+');
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | ShortcutBinding): string {
|
||||
return formatComboParts(binding).join(currentPlatform().isMac ? '' : '+');
|
||||
}
|
||||
|
||||
export function currentPlatform(): { isMac: boolean; isWindows: boolean } {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { DictationSession } from "../types"
|
||||
import type { ActivityDay, DictationSession } from "../types"
|
||||
import { invokeOrMock } from "./shared"
|
||||
import { mockHistory } from "./mock-data"
|
||||
import { mockActivityDays, mockHistory } from "./mock-data"
|
||||
|
||||
export function listHistory(): Promise<DictationSession[]> {
|
||||
return invokeOrMock("list_history", undefined, () => mockHistory)
|
||||
}
|
||||
|
||||
/** 每日听写活动计数(日期升序),概览页年度热力图数据源。与历史保留策略解耦。 */
|
||||
export function getActivityStats(): Promise<ActivityDay[]> {
|
||||
return invokeOrMock("get_activity_stats", undefined, () => mockActivityDays)
|
||||
}
|
||||
|
||||
export function deleteHistoryEntry(id: string): Promise<void> {
|
||||
return invokeOrMock("delete_history_entry", { id }, () => undefined)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
listHistory,
|
||||
deleteHistoryEntry,
|
||||
clearHistory,
|
||||
getActivityStats,
|
||||
readAudioRecording,
|
||||
retranscribeRecording,
|
||||
} from "./history"
|
||||
@@ -132,6 +133,7 @@ export {
|
||||
lessComputerWindowDismiss,
|
||||
lessComputerApprove,
|
||||
lessComputerWindowResize,
|
||||
lessComputerSubmitText,
|
||||
} from "./less-computer"
|
||||
|
||||
// updater
|
||||
@@ -158,11 +160,9 @@ export type {
|
||||
CodingAgentPermissionMode,
|
||||
McpHealth,
|
||||
CodingAgentEvent,
|
||||
} from "./coding-agent"
|
||||
export type {
|
||||
OpenCodeDetection,
|
||||
McpServerStatus,
|
||||
ClaudeDetection,
|
||||
OpenCodeDetection,
|
||||
CodingAgentRunTestArgs,
|
||||
} from "./coding-agent"
|
||||
export {
|
||||
|
||||
@@ -25,3 +25,12 @@ export function lessComputerWindowResize(height: number): Promise<void> {
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
/** 浮窗打字输入:文字指令直接进入 Less Computer 执行链(与语音同护栏/审批/连续会话)。 */
|
||||
export function lessComputerSubmitText(text: string): Promise<void> {
|
||||
return invokeOrMock(
|
||||
"less_computer_submit_text",
|
||||
{ text },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ActivityDay,
|
||||
CorrectionRule,
|
||||
DictationSession,
|
||||
DictionaryEntry,
|
||||
@@ -93,6 +94,7 @@ export let mockSettings: UserPreferences = {
|
||||
streamingInsert: true,
|
||||
streamingInsertDefaultMigrated: true,
|
||||
streamingInsertSaveClipboard: true,
|
||||
showOverviewActivityHeatmap: true,
|
||||
autoUpdateCheck: true,
|
||||
historyMaxEntries: null,
|
||||
recordAudioForDebug: false,
|
||||
@@ -632,3 +634,21 @@ export function mockImportStylePackFromZip(zipPath: string): StylePack {
|
||||
syncMockSettingsFromStylePacks()
|
||||
return cloneStylePack(pack)
|
||||
}
|
||||
|
||||
// ── 活动热力图(浏览器 dev 演示数据)────────────────────────────────────
|
||||
// 过去一年稀疏分布的日计数,铺出有疏密对比的热力图。种子取日期序号的伪随机,
|
||||
// 刷新之间保持稳定。
|
||||
export const mockActivityDays: ActivityDay[] = (() => {
|
||||
const days: ActivityDay[] = []
|
||||
const today = new Date()
|
||||
for (let i = 364; i >= 0; i -= 1) {
|
||||
const d = new Date(today)
|
||||
d.setDate(today.getDate() - i)
|
||||
const seed = Math.abs(Math.sin(i * 12.9898) * 43758.5453) % 1
|
||||
if (seed < 0.55) continue
|
||||
const count = Math.max(1, Math.round(seed * 22) - 8)
|
||||
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
days.push({ date: iso, count })
|
||||
}
|
||||
return days
|
||||
})()
|
||||
@@ -18,6 +18,7 @@ function assert(condition: boolean, message: string) {
|
||||
const previousPrefs: UserPreferences = {
|
||||
hotkey: { trigger: 'rightOption', mode: 'toggle' },
|
||||
dictationHotkey: { primary: 'RightOption', modifiers: [] },
|
||||
showOverviewActivityHeatmap: true,
|
||||
defaultMode: 'light',
|
||||
enabledModes: ['raw', 'light', 'structured'],
|
||||
activeStylePackId: '',
|
||||
|
||||
@@ -26,6 +26,12 @@ export type PolishMode = 'raw' | 'light' | 'structured' | 'formal';
|
||||
|
||||
export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed';
|
||||
|
||||
/** 概览页年度活动热力图的单日计数(date = 本地日期 YYYY-MM-DD)。 */
|
||||
export interface ActivityDay {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DictationSession {
|
||||
id: string;
|
||||
createdAt: string; // ISO-8601
|
||||
@@ -366,6 +372,8 @@ export interface UserPreferences {
|
||||
/** 流式输入成功后是否把最终润色文本写回剪贴板。开启后 Cmd+V 还能重复粘贴该次输出,
|
||||
* 与一次性路径行为对齐。默认 true。 */
|
||||
streamingInsertSaveClipboard: boolean;
|
||||
/** 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片,活动计数照常记录。 */
|
||||
showOverviewActivityHeatmap: boolean;
|
||||
/** 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。
|
||||
* Android:开启后自动检查并下载,校验后打开系统安装器。
|
||||
* 桌面:开启后自动检查,发现更新弹窗由用户确认安装。
|
||||
@@ -479,6 +487,8 @@ export type LessComputerEvent =
|
||||
| { kind: 'delta'; text: string }
|
||||
/** 工具调用提示(来自 CodingAgentEvent::ToolUse,如 "Bash")。 */
|
||||
| { kind: 'tool'; name: string }
|
||||
/** 会话上下文被压缩(来自 CodingAgentEvent::Compaction),输出流对应位置内嵌提示。 */
|
||||
| { kind: 'compaction' }
|
||||
/** 内联审批卡:高风险动作被护栏拦下,等用户 Approve / Deny。 */
|
||||
| { kind: 'approval'; token: string; command: string; reason: string }
|
||||
/** 运行完成:最终结果 + 成本(美元)。 */
|
||||
@@ -527,6 +537,12 @@ export interface CapsulePayload {
|
||||
translation: boolean;
|
||||
/** 当前是否是 Less Computer 会话:处理态文案显示 "using" 而非 "thinking"。 */
|
||||
operating?: boolean;
|
||||
/**
|
||||
* 预备态:胶囊已「乐观显示」(按下热键即弹出并播入场动画),但麦克风还没吐第一帧
|
||||
* PCM。为 true 时录音光条渲染成「待命」形态(柔和呼吸、不接真实电平),暗示用户稍候
|
||||
* 再开口;麦克风就绪后翻 false,光条点亮进入正式录音。只对 recording 有意义。
|
||||
*/
|
||||
warming?: boolean;
|
||||
}
|
||||
|
||||
export interface CredentialsStatus {
|
||||
|
||||
@@ -2,107 +2,100 @@
|
||||
// 只画贴边光带,不铺暗场;彩色光环沿边缘流动,模拟 Apple Intelligence 的发光描边。
|
||||
// 纯视觉:pointer-events:none,后端再 set_ignore_cursor_events(true)。仅 macOS 显示。
|
||||
//
|
||||
// 发光技法移植自 EdgeGlow(github.com/vector4wang/EdgeGlow,MIT):核心是「4 层模糊堆叠」——
|
||||
// 最内一条 0 模糊的锐利彩虹实线,外面叠 3 层逐渐变宽、变虚的光晕,得到霓虹灯管式发光。
|
||||
// 配色采用 EdgeGlow 的 iridescent(Apple Intelligence 高饱和霓虹)主题。
|
||||
// v2:单 pass WebGL fragment shader 取代旧的「4 层 conic-gradient + CSS blur」——
|
||||
// 旧方案靠 @property 角度动画每帧重光栅化 4 层渐变、再各做一次 blur(全屏 4 连模糊,
|
||||
// 开销大且 15px 的 blur 出不来宽光晕)。shader 里圆角矩形 SDF + 双指数距离衰减一次
|
||||
// 算出「锐利边线 + 内晕 + 宽尾外晕」(真·高斯质感),配色沿用 EdgeGlow iridescent
|
||||
// 12 色霓虹谱(紫→蓝→青→绿→金→橙→红→粉),沿边缘 8s 流动 + 慢呼吸。
|
||||
// 光晕是低频信号,内部按半分辨率渲染(RENDER_SCALE),5K 全屏也只是一次轻量 draw。
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// EdgeGlow iridescent 主题(紫→蓝→青→绿→金→橙→红→粉→紫),沿环 360° 均匀铺开。
|
||||
const SPECTRUM = `conic-gradient(from var(--lcg-angle),
|
||||
#a633f2 0deg,
|
||||
#594dff 30deg,
|
||||
#2680ff 60deg,
|
||||
#0db3fa 90deg,
|
||||
#1ad9e6 120deg,
|
||||
#33e6bf 150deg,
|
||||
#80d966 180deg,
|
||||
#ccb31a 210deg,
|
||||
#ff801a 240deg,
|
||||
#ff4059 270deg,
|
||||
#f22699 300deg,
|
||||
#bf40d9 330deg,
|
||||
#a633f2 360deg)`;
|
||||
const RENDER_SCALE = 0.5;
|
||||
/** 与旧 CSS 的 --lcg-radius 默认值一致:贴合屏幕物理圆角。 */
|
||||
const SCREEN_CORNER_RADIUS = 42;
|
||||
|
||||
const VERTEX_SRC = 'attribute vec2 aPos; void main(){ gl_Position=vec4(aPos,0.0,1.0); }';
|
||||
|
||||
// EdgeGlow iridescent 主题 12 色停靠(与旧 conic-gradient 相同),shader 内线性插值。
|
||||
const GLOW_FRAGMENT_SRC = `
|
||||
precision highp float;
|
||||
uniform vec2 iResolution; uniform float iTime;
|
||||
uniform float uRadius;
|
||||
const float TAU = 6.28318530718;
|
||||
|
||||
vec3 spectrum(float h) {
|
||||
vec3 c0 = vec3(0.651, 0.200, 0.949); /* #a633f2 */
|
||||
vec3 c1 = vec3(0.349, 0.302, 1.000); /* #594dff */
|
||||
vec3 c2 = vec3(0.149, 0.502, 1.000); /* #2680ff */
|
||||
vec3 c3 = vec3(0.051, 0.702, 0.980); /* #0db3fa */
|
||||
vec3 c4 = vec3(0.102, 0.851, 0.902); /* #1ad9e6 */
|
||||
vec3 c5 = vec3(0.200, 0.902, 0.749); /* #33e6bf */
|
||||
vec3 c6 = vec3(0.502, 0.851, 0.400); /* #80d966 */
|
||||
vec3 c7 = vec3(0.800, 0.702, 0.102); /* #ccb31a */
|
||||
vec3 c8 = vec3(1.000, 0.502, 0.102); /* #ff801a */
|
||||
vec3 c9 = vec3(1.000, 0.251, 0.349); /* #ff4059 */
|
||||
vec3 ca = vec3(0.949, 0.149, 0.600); /* #f22699 */
|
||||
vec3 cb = vec3(0.749, 0.251, 0.851); /* #bf40d9 */
|
||||
float x = fract(h) * 12.0;
|
||||
float i = floor(x);
|
||||
float f = x - i;
|
||||
vec3 a = c0; vec3 b = c1;
|
||||
if (i < 1.0) { a = c0; b = c1; }
|
||||
else if (i < 2.0) { a = c1; b = c2; }
|
||||
else if (i < 3.0) { a = c2; b = c3; }
|
||||
else if (i < 4.0) { a = c3; b = c4; }
|
||||
else if (i < 5.0) { a = c4; b = c5; }
|
||||
else if (i < 6.0) { a = c5; b = c6; }
|
||||
else if (i < 7.0) { a = c6; b = c7; }
|
||||
else if (i < 8.0) { a = c7; b = c8; }
|
||||
else if (i < 9.0) { a = c8; b = c9; }
|
||||
else if (i < 10.0){ a = c9; b = ca; }
|
||||
else if (i < 11.0){ a = ca; b = cb; }
|
||||
else { a = cb; b = c0; }
|
||||
return mix(a, b, f);
|
||||
}
|
||||
|
||||
/* 圆角矩形 SDF(外侧为正)。 */
|
||||
float sdRoundRect(vec2 p, vec2 half_, float r) {
|
||||
vec2 q = abs(p) - (half_ - vec2(r));
|
||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 R = iResolution.xy;
|
||||
vec2 p = gl_FragCoord.xy - R * 0.5;
|
||||
/* 到屏幕边缘的内侧距离(px,边缘=0 向屏内增大)。 */
|
||||
float d = max(-sdRoundRect(p, R * 0.5, uRadius), 0.0);
|
||||
|
||||
float t = iTime;
|
||||
/* 色相 = 边缘角向位置 + 8s/圈 流动(与旧 conic 自旋同速)。 */
|
||||
float hue = fract(atan(p.y, p.x) / TAU + t / 8.0);
|
||||
vec3 col = spectrum(hue);
|
||||
/* 旧版各层 saturate(1.2~1.3)·brightness(1.05) 的霓虹增益。 */
|
||||
col = clamp((col - 0.5) * 1.25 + 0.5, 0.0, 1.0) * 1.06;
|
||||
|
||||
/* 发光剖面:锐利边线 + 内晕 + 宽尾外晕(双指数 ≈ 高斯拖尾)。 */
|
||||
float line = smoothstep(3.5, 0.6, d) * 1.05;
|
||||
float inner = exp(-d / 15.0) * 0.60;
|
||||
float outer = exp(-d / 52.0) * 0.34;
|
||||
/* 慢呼吸(旧 lcg-breathe 的合并版)。 */
|
||||
float breathe = 1.0 + 0.12 * sin(t * 1.15) * sin(t * 0.53 + 1.7);
|
||||
float glow = (line + inner + outer) * breathe;
|
||||
|
||||
vec3 rgb = col * glow;
|
||||
float a = clamp(max(rgb.r, max(rgb.g, rgb.b)), 0.0, 1.0);
|
||||
gl_FragColor = vec4(rgb, a);
|
||||
}`;
|
||||
|
||||
const glowCss = `
|
||||
@property --lcg-angle { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
|
||||
@keyframes lcg-spin { to { --lcg-angle: 360deg; } }
|
||||
@keyframes lcg-breathe { 0%, 100% { opacity: var(--lcg-o); } 50% { opacity: calc(var(--lcg-o) + .14); } }
|
||||
|
||||
html, body, #root { background: transparent !important; margin: 0; height: 100%; overflow: hidden; }
|
||||
|
||||
/* 全屏裁剪容器:圆角贴合屏幕物理圆角;overflow:hidden 把外溢模糊裁在屏幕边缘内侧。 */
|
||||
.lcg-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
border-radius: var(--lcg-radius, 42px);
|
||||
}
|
||||
|
||||
/* 4 层都是同一个「边框环」:用 mask-composite 只保留 padding 那一圈,padding 即环的粗细。 */
|
||||
.lcg-line,
|
||||
.lcg-glow-1,
|
||||
.lcg-glow-2,
|
||||
.lcg-glow-3 {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: calc(var(--lcg-radius, 42px) + 1px);
|
||||
background: ${SPECTRUM};
|
||||
-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;
|
||||
pointer-events: none;
|
||||
will-change: filter, opacity, --lcg-angle;
|
||||
}
|
||||
|
||||
/* 第 4 层 → 最细的彩虹实线(EdgeGlow blur=0 的那层):几乎不模糊,颜色最实,定义边缘。 */
|
||||
.lcg-line {
|
||||
--lcg-o: 1;
|
||||
padding: 2.5px;
|
||||
filter: saturate(1.32) brightness(1.06) blur(.5px)
|
||||
drop-shadow(0 0 4px rgba(150, 120, 255, .55));
|
||||
opacity: 1;
|
||||
animation: lcg-spin 8s linear infinite;
|
||||
}
|
||||
|
||||
/* 第 3 层 → 内层亮边(EdgeGlow blur≈2)。 */
|
||||
.lcg-glow-1 {
|
||||
--lcg-o: .85;
|
||||
padding: 5px;
|
||||
filter: saturate(1.28) brightness(1.05) blur(3px);
|
||||
opacity: .85;
|
||||
mix-blend-mode: screen;
|
||||
animation: lcg-spin 8s linear infinite, lcg-breathe 4.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 第 2 层 → 中层光晕(EdgeGlow blur≈8)。 */
|
||||
.lcg-glow-2 {
|
||||
--lcg-o: .56;
|
||||
padding: 11px;
|
||||
filter: saturate(1.22) brightness(1.04) blur(8px);
|
||||
opacity: .56;
|
||||
mix-blend-mode: screen;
|
||||
animation: lcg-spin 8s linear infinite, lcg-breathe 5.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 第 1 层 → 最宽外晕(EdgeGlow blur≈12),向屏内柔和散开。 */
|
||||
.lcg-glow-3 {
|
||||
--lcg-o: .4;
|
||||
padding: 20px;
|
||||
filter: saturate(1.18) brightness(1.03) blur(15px);
|
||||
opacity: .4;
|
||||
mix-blend-mode: screen;
|
||||
animation: lcg-spin 8s linear infinite, lcg-breathe 6.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lcg-line,
|
||||
.lcg-glow-1,
|
||||
.lcg-glow-2,
|
||||
.lcg-glow-3 { animation: none; }
|
||||
}
|
||||
.lcg-root canvas { display: block; width: 100%; height: 100%; }
|
||||
`;
|
||||
|
||||
if (typeof document !== 'undefined' && !document.getElementById('less-computer-glow-style')) {
|
||||
@@ -112,10 +105,93 @@ if (typeof document !== 'undefined' && !document.getElementById('less-computer-g
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
function GlowCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
const gl = canvas.getContext('webgl', {
|
||||
alpha: true,
|
||||
premultipliedAlpha: true,
|
||||
antialias: false,
|
||||
});
|
||||
if (!gl) return undefined;
|
||||
|
||||
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('[LessComputerGlow] 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, GLOW_FRAGMENT_SRC);
|
||||
if (!vs || !fs) return undefined;
|
||||
const program = gl.createProgram();
|
||||
if (!program) return undefined;
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.error('[LessComputerGlow] program link failed:', gl.getProgramInfoLog(program));
|
||||
return undefined;
|
||||
}
|
||||
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 uRadius = gl.getUniformLocation(program, 'uRadius');
|
||||
|
||||
let raf = 0;
|
||||
const start = performance.now();
|
||||
const frame = (now: number) => {
|
||||
const scale = Math.min(window.devicePixelRatio || 1, 2) * RENDER_SCALE;
|
||||
const w = Math.max(1, Math.round(canvas.clientWidth * scale));
|
||||
const h = Math.max(1, Math.round(canvas.clientHeight * scale));
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
gl.viewport(0, 0, w, h);
|
||||
}
|
||||
gl.uniform2f(uResolution, w, h);
|
||||
gl.uniform1f(uTime, (now - start) / 1000);
|
||||
if (uRadius) gl.uniform1f(uRadius, SCREEN_CORNER_RADIUS * scale);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={canvasRef} aria-hidden />;
|
||||
}
|
||||
|
||||
export function LessComputerGlow() {
|
||||
// issue #470:窗口 .hide() 后 webview 不会自动停掉这些无限动画(Windows 尤其不释放),
|
||||
// 全屏发光层持续占 GPU 合成。改由后端 show/hide 主动 emit 可见状态驱动:不可见时直接卸载
|
||||
// 发光层(无元素 → 零 GPU),可见时原样渲染——显示时视觉零变化。
|
||||
// issue #470:窗口 .hide() 后 webview 不会自动停掉动画循环,全屏发光层持续占 GPU。
|
||||
// 由后端 show/hide 主动 emit 可见状态驱动:不可见时直接卸载(rAF 停止 + GL 释放 →
|
||||
// 零 GPU),可见时重新挂载 —— 显示时视觉零变化。
|
||||
const [active, setActive] = useState(true);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -144,10 +220,7 @@ export function LessComputerGlow() {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<div className="lcg-root" aria-hidden>
|
||||
<span className="lcg-glow-3" />
|
||||
<span className="lcg-glow-2" />
|
||||
<span className="lcg-glow-1" />
|
||||
<span className="lcg-line" />
|
||||
<GlowCanvas />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
// LessComputerPanel.tsx — Less Computer 语音 Agent 浮窗(窗口 label = "less-computer")。
|
||||
//
|
||||
// 把「按住专用键说话 → Agent 操控电脑」的交互渲染成聊天结构:
|
||||
// 把「按住专用键说话 → Agent 操控电脑」的交互渲染成 agent 输出流(Codex 风格):
|
||||
// - 用户气泡:语音指令转写(`user` 事件,开启新会话并清空旧内容)。
|
||||
// - 助手气泡:流式回复(`delta` 累积)+ 工具调用 chip(`tool`)。
|
||||
// - 助手输出:文本与工具调用**按到达顺序交错**成一条文档流 —— 文本段落之间
|
||||
// 穿插工具行(纯文字,无图标无边框),进行中文字扫光,结束后停光继续往下。
|
||||
// - 内联审批卡(`approval`):高风险动作被护栏拦下时弹 Approve / Deny。
|
||||
// - 完成(`completed`)落最终结果 + 成本;出错(`error`)红色样式。
|
||||
// - 完成(`completed`)落成本;出错(`error`)红色样式。
|
||||
// 视觉:shadcn Card 消息窗(主题 token 自适应浅/深色)。
|
||||
//
|
||||
// 窗口随内容自适应高度(measure content → setSize),不可拖动、置顶、磨砂。
|
||||
// 窗口随内容自适应高度(measure content → setSize),不可拖动、置顶。
|
||||
// 仅 macOS 实际触发(后端只在 macOS 注册 Less Computer 热键并 emit 事件)。
|
||||
// 关闭:Esc / ✕ → less_computer_window_dismiss → 后端隐藏窗口。
|
||||
|
||||
@@ -20,23 +22,35 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useRafThrottle } from '../lib/useRafThrottle';
|
||||
import { Marker, MarkerContent, MarkerIcon } from '../components/Marker';
|
||||
import { MessageScroller } from '../components/MessageScroller';
|
||||
import { ThinkingDots } from '../components/ThinkingDots';
|
||||
import { SiriGL } from '../components/SiriGL';
|
||||
import {
|
||||
isTauri,
|
||||
lessComputerApprove,
|
||||
lessComputerSubmitText,
|
||||
lessComputerWindowDismiss,
|
||||
lessComputerWindowResize,
|
||||
} from '../lib/ipc';
|
||||
import type { LessComputerEvent } from '../lib/types';
|
||||
import type { CapsulePayload, LessComputerEvent } from '../lib/types';
|
||||
import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown';
|
||||
|
||||
type RunStatus = 'idle' | 'working' | 'done' | 'error' | 'cancelled';
|
||||
|
||||
interface ToolChip {
|
||||
kind: 'tool';
|
||||
name: string;
|
||||
interface TextSegment {
|
||||
kind: 'text';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ApprovalCard {
|
||||
interface ToolSegment {
|
||||
kind: 'tool';
|
||||
name: string;
|
||||
/** 后端没有工具结束事件:下一个事件(delta/tool/approval/收尾)到达即视为结束。 */
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
interface ApprovalSegment {
|
||||
kind: 'approval';
|
||||
token: string;
|
||||
command: string;
|
||||
@@ -45,14 +59,17 @@ interface ApprovalCard {
|
||||
decision?: 'approved' | 'denied';
|
||||
}
|
||||
|
||||
/** 助手回复流里穿插的工具 chip / 审批卡,按到达顺序排列。 */
|
||||
type Activity = ToolChip | ApprovalCard;
|
||||
interface CompactionSegment {
|
||||
kind: 'compaction';
|
||||
}
|
||||
|
||||
/** 一轮对话:用户一句 + 助手流式回复 + 其间的工具/审批 + 本轮收尾态。连续对话累积成数组。 */
|
||||
/** 助手输出流:文本 / 工具行 / 上下文压缩 / 审批卡按到达顺序排列(Codex 式交错)。 */
|
||||
type Segment = TextSegment | ToolSegment | CompactionSegment | ApprovalSegment;
|
||||
|
||||
/** 一轮对话:用户一句 + 助手输出流 + 本轮收尾态。连续对话累积成数组。 */
|
||||
interface Turn {
|
||||
user: string;
|
||||
answer: string;
|
||||
activities: Activity[];
|
||||
segments: Segment[];
|
||||
status: RunStatus;
|
||||
errorMsg: string;
|
||||
costUsd: number | null;
|
||||
@@ -64,14 +81,53 @@ function updateLastTurn(turns: Turn[], fn: (t: Turn) => Turn): Turn[] {
|
||||
return [...turns.slice(0, -1), fn(turns[turns.length - 1])];
|
||||
}
|
||||
|
||||
const WINDOW_MIN_HEIGHT = 120;
|
||||
/** 把流里还在扫光的工具行停下来(下一个事件到达 = 上一个工具已结束)。 */
|
||||
function settleRunningTools(segments: Segment[]): Segment[] {
|
||||
if (!segments.some(s => s.kind === 'tool' && s.running)) return segments;
|
||||
return segments.map(s => (s.kind === 'tool' && s.running ? { ...s, running: false } : s));
|
||||
}
|
||||
|
||||
const WINDOW_MIN_HEIGHT = 140;
|
||||
const WINDOW_MAX_HEIGHT = 520;
|
||||
const TOOLBAR_HEIGHT = 28;
|
||||
/** ChatHeader 的固定高度(标题 + 状态行两行),窗口自适应测量时加上它。 */
|
||||
const TOOLBAR_HEIGHT = 56;
|
||||
/** 底部输入区(Composer)的固定高度,窗口自适应测量时加上它。 */
|
||||
const COMPOSER_HEIGHT = 58;
|
||||
|
||||
// 浏览器预览(vite dev,非 Tauri):?window=less-computer&demo=1 注入一轮演示对话,
|
||||
// 覆盖「文本 → 扫光工具行 → 文本 → 已停光工具行 → 审批卡」的交错流,方便调样式。
|
||||
function getPreviewTurns(): Turn[] {
|
||||
if (isTauri || typeof window === 'undefined') return [];
|
||||
if (new URLSearchParams(window.location.search).get('demo') !== '1') return [];
|
||||
return [
|
||||
{
|
||||
user: '帮我把桌面上的截图整理到「本周素材」文件夹',
|
||||
segments: [
|
||||
{ kind: 'text', content: '好的,我先看一下桌面上有哪些截图。' },
|
||||
{ kind: 'tool', name: 'Bash', running: false },
|
||||
{ kind: 'text', content: '找到 6 张截图,正在移动并按日期重命名…' },
|
||||
{ kind: 'tool', name: 'Bash', running: true },
|
||||
{
|
||||
kind: 'approval',
|
||||
token: 'demo',
|
||||
command: 'mv ~/Desktop/Screenshot*.png ~/Documents/本周素材/',
|
||||
reason: 'Moving files outside the working directory.',
|
||||
},
|
||||
],
|
||||
status: 'working',
|
||||
errorMsg: '',
|
||||
costUsd: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function LessComputerPanel() {
|
||||
const { t } = useTranslation();
|
||||
// 连续对话:每按一次说话键追加一轮(除非后端标记 fresh=新会话则清空重开)。
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [turns, setTurns] = useState<Turn[]>(getPreviewTurns);
|
||||
// 新会话计数:fresh 时 +1,作为 shell 的 key 重放入场动画 —— 浮窗是常驻
|
||||
// webview(hide/show 复用),没有这个的话再次唤起时内容直接闪现,很突兀。
|
||||
const [sessionSeq, setSessionSeq] = useState(0);
|
||||
|
||||
// ── 后端事件订阅(mount 一次)────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -103,26 +159,52 @@ export function LessComputerPanel() {
|
||||
// 一轮新对话。fresh=true(后端无可续会话→新会话)则清空历史重开;否则追加为后续轮次。
|
||||
const fresh: Turn = {
|
||||
user: ev.text,
|
||||
answer: '',
|
||||
activities: [],
|
||||
segments: [],
|
||||
status: 'working',
|
||||
errorMsg: '',
|
||||
costUsd: null,
|
||||
};
|
||||
setTurns(prev => (ev.fresh ? [fresh] : [...prev, fresh]));
|
||||
if (ev.fresh) setSessionSeq(seq => seq + 1);
|
||||
break;
|
||||
}
|
||||
case 'started':
|
||||
setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, status: 'working' })));
|
||||
break;
|
||||
case 'delta':
|
||||
setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, answer: tn.answer + ev.text })));
|
||||
setTurns(prev =>
|
||||
updateLastTurn(prev, tn => {
|
||||
const segments = settleRunningTools(tn.segments);
|
||||
const last = segments[segments.length - 1];
|
||||
if (last?.kind === 'text') {
|
||||
return {
|
||||
...tn,
|
||||
segments: [
|
||||
...segments.slice(0, -1),
|
||||
{ ...last, content: last.content + ev.text },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ...tn, segments: [...segments, { kind: 'text', content: ev.text }] };
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'tool':
|
||||
setTurns(prev =>
|
||||
updateLastTurn(prev, tn => ({
|
||||
...tn,
|
||||
activities: [...tn.activities, { kind: 'tool', name: ev.name }],
|
||||
segments: [
|
||||
...settleRunningTools(tn.segments),
|
||||
{ kind: 'tool', name: ev.name, running: true },
|
||||
],
|
||||
})),
|
||||
);
|
||||
break;
|
||||
case 'compaction':
|
||||
setTurns(prev =>
|
||||
updateLastTurn(prev, tn => ({
|
||||
...tn,
|
||||
segments: [...settleRunningTools(tn.segments), { kind: 'compaction' }],
|
||||
})),
|
||||
);
|
||||
break;
|
||||
@@ -130,8 +212,8 @@ export function LessComputerPanel() {
|
||||
setTurns(prev =>
|
||||
updateLastTurn(prev, tn => ({
|
||||
...tn,
|
||||
activities: [
|
||||
...tn.activities,
|
||||
segments: [
|
||||
...settleRunningTools(tn.segments),
|
||||
{ kind: 'approval', token: ev.token, command: ev.command, reason: ev.reason },
|
||||
],
|
||||
})),
|
||||
@@ -139,19 +221,35 @@ export function LessComputerPanel() {
|
||||
break;
|
||||
case 'completed':
|
||||
setTurns(prev =>
|
||||
updateLastTurn(prev, tn => ({
|
||||
...tn,
|
||||
answer: ev.text || tn.answer,
|
||||
costUsd: ev.costUsd ?? null,
|
||||
status: 'done',
|
||||
})),
|
||||
updateLastTurn(prev, tn => {
|
||||
let segments = settleRunningTools(tn.segments);
|
||||
// 正常情况最终文本已通过 delta 流出;只有整轮没有任何文本时才用
|
||||
// completed 的成品兜底(否则会把穿插的工具行冲掉)。
|
||||
if (ev.text && !segments.some(s => s.kind === 'text')) {
|
||||
segments = [...segments, { kind: 'text', content: ev.text }];
|
||||
}
|
||||
return { ...tn, segments, costUsd: ev.costUsd ?? null, status: 'done' };
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'error':
|
||||
setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, errorMsg: ev.message, status: 'error' })));
|
||||
setTurns(prev =>
|
||||
updateLastTurn(prev, tn => ({
|
||||
...tn,
|
||||
segments: settleRunningTools(tn.segments),
|
||||
errorMsg: ev.message,
|
||||
status: 'error',
|
||||
})),
|
||||
);
|
||||
break;
|
||||
case 'cancelled':
|
||||
setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, status: 'cancelled' })));
|
||||
setTurns(prev =>
|
||||
updateLastTurn(prev, tn => ({
|
||||
...tn,
|
||||
segments: settleRunningTools(tn.segments),
|
||||
status: 'cancelled',
|
||||
})),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -160,10 +258,10 @@ export function LessComputerPanel() {
|
||||
setTurns(prev =>
|
||||
prev.map(tn => ({
|
||||
...tn,
|
||||
activities: tn.activities.map(a =>
|
||||
a.kind === 'approval' && a.token === token
|
||||
? { ...a, decision: approved ? 'approved' : 'denied' }
|
||||
: a,
|
||||
segments: tn.segments.map(s =>
|
||||
s.kind === 'approval' && s.token === token
|
||||
? { ...s, decision: approved ? 'approved' : ('denied' as const) }
|
||||
: s,
|
||||
),
|
||||
})),
|
||||
);
|
||||
@@ -184,57 +282,167 @@ export function LessComputerPanel() {
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, []);
|
||||
|
||||
// ── 内容自适应:measure 内容高 + toolbar → 回传后端 clamp + bottom-anchored 摆放,
|
||||
// 让内容增长向上撑开。超出 max 则窗口内部滚动。
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const resizeFrame = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
// ── 内容自适应:MessageScroller 的 ResizeObserver 报告内容高(含 markdown 渲染
|
||||
// 引起的变化)→ 回传后端 clamp + bottom-anchored 摆放。超出 max 则内部滚动。
|
||||
const onContentResize = (height: number) => {
|
||||
if (!isTauri) return;
|
||||
// 把「测量内容高(强制布局)+ 跨进程 IPC resize」坍缩到每帧最多一次:流式回复
|
||||
// 每个 token 都会触发这个 effect,原本每 token 一次 layout + 一次 IPC。rAF 合并成
|
||||
// ~60fps,且回调里读到的是最新 DOM。
|
||||
if (resizeFrame.current != null) return;
|
||||
resizeFrame.current = requestAnimationFrame(() => {
|
||||
resizeFrame.current = null;
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const measured = Math.ceil(el.scrollHeight) + TOOLBAR_HEIGHT;
|
||||
const target = Math.min(WINDOW_MAX_HEIGHT, Math.max(WINDOW_MIN_HEIGHT, measured));
|
||||
void lessComputerWindowResize(target);
|
||||
});
|
||||
}, [turns]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resizeFrame.current != null) cancelAnimationFrame(resizeFrame.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const target = Math.min(
|
||||
WINDOW_MAX_HEIGHT,
|
||||
Math.max(WINDOW_MIN_HEIGHT, height + TOOLBAR_HEIGHT + COMPOSER_HEIGHT),
|
||||
);
|
||||
void lessComputerWindowResize(target);
|
||||
};
|
||||
|
||||
// 自动滚动到底
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [turns]);
|
||||
const working = turns.some(tn => tn.status === 'working');
|
||||
|
||||
return (
|
||||
<div className="ol-frost lc-shell" style={shellStyle}>
|
||||
<div className="lc-bg" aria-hidden />
|
||||
<Toolbar label={t('lessComputer.closeTooltip')} onClose={onClose} />
|
||||
<div ref={scrollRef} style={scrollStyle}>
|
||||
<div ref={contentRef} style={contentStyle}>
|
||||
{turns.map((turn, ti) => (
|
||||
<TurnView key={ti} turn={turn} onApproval={onApproval} t={t} />
|
||||
))}
|
||||
// key=sessionSeq:新会话唤起时重放 lc-shell-in 入场(浮窗窗口复用不重挂载)。
|
||||
<div key={sessionSeq} className="lc-shell lc-shell-in" style={shellStyle}>
|
||||
{/* 消息弹窗结构(用户指定的聊天卡片规范):header 常驻标题 + 工作状态
|
||||
(「正在操控电脑…」扫光)在上方显示,右上角 ✕。 */}
|
||||
<ChatHeader
|
||||
title={t('lessComputer.title')}
|
||||
status={working ? t('lessComputer.working') : undefined}
|
||||
closeLabel={t('lessComputer.closeTooltip')}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{/* 滚动行为遵循聊天滚动标准(MessageScroller):新轮次锚定视口顶部、
|
||||
读者离底不跟随、屏外增长浮现「跳到最新」。 */}
|
||||
<MessageScroller
|
||||
anchorKey={`${sessionSeq}-${turns.length}`}
|
||||
defaultScrollPosition="last-anchor"
|
||||
busy={working}
|
||||
onContentResize={onContentResize}
|
||||
jumpLabel={t('lessComputer.jumpToLatest')}
|
||||
style={scrollStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{turns.map((turn, ti) => (
|
||||
<TurnView key={ti} turn={turn} onApproval={onApproval} t={t} />
|
||||
))}
|
||||
</MessageScroller>
|
||||
<Composer working={working} t={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部输入区(shadcn chat demo 的 CardFooter 复刻):打字输入 + 发送按钮。
|
||||
* 语音状态内嵌(用户拍板):按住 Less Computer 键说话时这里变成胶囊同款
|
||||
* 彩虹光条(SiriGL wave,真实电平驱动),转写/思考时变成六点圆环 —— 通过监听
|
||||
* capsule:state 的 operating 会话获得状态与电平,与屏幕下方胶囊同一数据源。
|
||||
*/
|
||||
function Composer({
|
||||
working,
|
||||
t,
|
||||
}: {
|
||||
working: boolean;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
}) {
|
||||
const [text, setText] = useState('');
|
||||
const [voice, setVoice] = useState<{ state: 'recording' | 'thinking'; level: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
const handle = await listen<CapsulePayload>('capsule:state', event => {
|
||||
const p = event.payload;
|
||||
if (p.operating !== true) return;
|
||||
if (p.state === 'recording') setVoice({ state: 'recording', level: p.level ?? 0 });
|
||||
else if (p.state === 'transcribing' || p.state === 'polishing')
|
||||
setVoice({ state: 'thinking', level: 0 });
|
||||
else setVoice(null);
|
||||
});
|
||||
if (cancelled) handle();
|
||||
else unlisten = handle;
|
||||
} catch (error) {
|
||||
console.error('[LessComputer] capsule listener setup failed', error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const send = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || working) return;
|
||||
setText('');
|
||||
void lessComputerSubmitText(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={composerStyle}>
|
||||
{voice ? (
|
||||
<div style={{ flex: 1, height: 42, position: 'relative' }}>
|
||||
{voice.state === 'recording' ? (
|
||||
<SiriGL
|
||||
mode="wave"
|
||||
level={voice.level}
|
||||
resolved
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, height: '100%' }}>
|
||||
<ThinkingDots size={18} />
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>
|
||||
{t('lessComputer.working')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={event => setText(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
placeholder={t('lessComputer.inputPlaceholder')}
|
||||
rows={1}
|
||||
style={composerInputStyle}
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!text.trim() || working}
|
||||
aria-label={t('lessComputer.send')}
|
||||
title={t('lessComputer.send')}
|
||||
style={{
|
||||
...sendButtonStyle,
|
||||
opacity: !text.trim() || working ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" aria-hidden>
|
||||
<path
|
||||
d="M6.5 11V2M2.5 6 6.5 2l4 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 子组件 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 渲染单轮对话:用户气泡 → 工具/审批 → 助手流式回复 → 收尾(错误/花费)。 */
|
||||
/** 渲染单轮对话:用户气泡 → 输出流(文本/工具行/审批卡交错)→ 收尾(错误/花费)。 */
|
||||
function TurnView({
|
||||
turn,
|
||||
onApproval,
|
||||
@@ -244,18 +452,37 @@ function TurnView({
|
||||
onApproval: (token: string, approved: boolean) => void;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
}) {
|
||||
const lastSegment = turn.segments[turn.segments.length - 1];
|
||||
// 等待指示只在流里没有「自带活动感」的内容时出现:整轮还没输出,或审批已决、
|
||||
// 后端正在带着授权重跑。running 工具行的扫光本身就是活动指示。
|
||||
const waiting =
|
||||
turn.status === 'working' &&
|
||||
(turn.segments.length === 0 ||
|
||||
(lastSegment?.kind === 'approval' && lastSegment.decision != null));
|
||||
return (
|
||||
<>
|
||||
<UserBubble text={turn.user} label={t('lessComputer.you')} />
|
||||
{turn.activities.map((a, i) =>
|
||||
a.kind === 'tool' ? (
|
||||
<ToolChipRow key={`t${i}`} name={a.name} t={t} />
|
||||
) : (
|
||||
<ApprovalRow key={a.token} card={a} onDecide={onApproval} t={t} />
|
||||
),
|
||||
)}
|
||||
{turn.answer && <AssistantBubble markdown={turn.answer} working={turn.status === 'working'} />}
|
||||
{turn.status === 'working' && !turn.answer && <WorkingRow label={t('lessComputer.working')} />}
|
||||
{turn.segments.map((segment, i) => {
|
||||
if (segment.kind === 'text') {
|
||||
const streaming =
|
||||
turn.status === 'working' && i === turn.segments.length - 1;
|
||||
return <AssistantText key={`s${i}`} markdown={segment.content} streaming={streaming} />;
|
||||
}
|
||||
if (segment.kind === 'tool') {
|
||||
return (
|
||||
<ToolLine
|
||||
key={`s${i}`}
|
||||
label={t('lessComputer.tool', { name: segment.name })}
|
||||
running={segment.running}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (segment.kind === 'compaction') {
|
||||
return <CompactionLine key={`s${i}`} label={t('lessComputer.compaction')} />;
|
||||
}
|
||||
return <ApprovalRow key={segment.token} card={segment} onDecide={onApproval} t={t} />;
|
||||
})}
|
||||
{waiting && <WorkingRow label={t('lessComputer.working')} />}
|
||||
{turn.status === 'error' && <ErrorRow message={turn.errorMsg || t('lessComputer.error')} />}
|
||||
{turn.status === 'cancelled' && <CostRow label={t('common.cancelled')} />}
|
||||
{turn.status === 'done' && turn.costUsd != null && (
|
||||
@@ -265,19 +492,42 @@ function TurnView({
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ label, onClose }: { label: string; onClose: () => void }) {
|
||||
/**
|
||||
* 聊天卡片头(消息弹窗规范):标题 + 工作状态行(「正在操控电脑…」扫光)常驻上方,
|
||||
* 右上角 ✕。整条作为拖动把手:按住空白处可把聊天框拖到屏幕任意位置。
|
||||
*/
|
||||
function ChatHeader({
|
||||
title,
|
||||
status,
|
||||
closeLabel,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
status?: string;
|
||||
closeLabel: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
// 顶栏作为拖动把手:按住空白处可把整个聊天框拖到屏幕任意位置(resize 会保住拖后的位置)。
|
||||
<div data-tauri-drag-region style={{ ...toolbarStyle, cursor: 'grab' }}>
|
||||
<div data-tauri-drag-region style={{ flex: 1, alignSelf: 'stretch' }} />
|
||||
<div data-tauri-drag-region style={{ ...headerStyle, cursor: 'grab' }}>
|
||||
<div data-tauri-drag-region style={{ flex: 1, minWidth: 0 }}>
|
||||
<div data-tauri-drag-region style={headerTitleStyle}>{title}</div>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
role={status ? 'status' : undefined}
|
||||
className={status ? 'lc-tool-shimmer' : undefined}
|
||||
style={headerStatusStyle}
|
||||
>
|
||||
{status ?? ' '}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
title={closeLabel}
|
||||
aria-label={closeLabel}
|
||||
style={closeBtnStyle}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11">
|
||||
@@ -295,14 +545,20 @@ function Toolbar({ label, onClose }: { label: string; onClose: () => void }) {
|
||||
|
||||
function UserBubble({ text, label }: { text: string; label: string }) {
|
||||
return (
|
||||
<div className="lc-enter" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 3 }}>
|
||||
// data-scroll-anchor:用户消息是新轮次的锚点行(MessageScroller 把它定位到视口顶部附近)。
|
||||
<div
|
||||
className="lc-enter"
|
||||
data-scroll-anchor
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 3 }}
|
||||
>
|
||||
<span style={roleLabelStyle}>{label}</span>
|
||||
<div style={userBubbleStyle}>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantBubble({ markdown, working }: { markdown: string; working: boolean }) {
|
||||
/** 助手文本段:无气泡的文档流(agent 输出风格),流式时末尾带光标。 */
|
||||
function AssistantText({ markdown, streaming }: { markdown: string; streaming: boolean }) {
|
||||
// 按帧率节流:流式回复逐 token 全量 parse + DOMPurify 是 O(n²),长回复越来越卡。
|
||||
const throttled = useRafThrottle(markdown);
|
||||
const html = useMemo(() => {
|
||||
@@ -318,43 +574,54 @@ function AssistantBubble({ markdown, working }: { markdown: string; working: boo
|
||||
return DOMPurify.sanitize(rendered, { ADD_ATTR: ['target', 'rel'] });
|
||||
}, [throttled]);
|
||||
return (
|
||||
<div className="lc-enter" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
|
||||
<div className="lc-enter" style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 3 }}>
|
||||
<div
|
||||
className="lc-answer"
|
||||
style={assistantBubbleStyle}
|
||||
style={assistantTextStyle}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
{working && <span style={caretStyle} />}
|
||||
{streaming && <span style={caretStyle} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolChipRow({
|
||||
name,
|
||||
t,
|
||||
}: {
|
||||
name: string;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
}) {
|
||||
/**
|
||||
* 工具行(Codex 式,Marker 语义):纯文字穿插在输出流里,无图标无边框。
|
||||
* 进行中 role="status"(辅助技术播报)+ 文字扫光(background-clip:text,
|
||||
* 元素只有一行小字,重绘面积可忽略),结束后停光变普通淡字。
|
||||
*/
|
||||
function ToolLine({ label, running }: { label: string; running: boolean }) {
|
||||
return (
|
||||
<div className="lc-enter" style={{ display: 'flex' }}>
|
||||
<span style={toolChipStyle}>
|
||||
<span aria-hidden style={{ marginRight: 4 }}>
|
||||
{'\u{1F6E0}'}
|
||||
</span>
|
||||
{t('lessComputer.tool', { name })}
|
||||
</span>
|
||||
</div>
|
||||
<Marker className="lc-enter" role={running ? 'status' : undefined}>
|
||||
<MarkerContent className={running ? 'lc-tool lc-tool-shimmer' : 'lc-tool'}>
|
||||
{label}
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
/** 上下文压缩行:separator 变体,横贯输出流标出压缩边界(穿插在它实际发生的位置)。 */
|
||||
function CompactionLine({ label }: { label: string }) {
|
||||
return (
|
||||
<Marker className="lc-enter" variant="separator">
|
||||
<MarkerContent style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontWeight: 500 }}>
|
||||
{label}
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingRow({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={dotStyle} />
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>{label}</span>
|
||||
</div>
|
||||
<Marker role="status" style={{ gap: 8 }}>
|
||||
<MarkerIcon>
|
||||
<ThinkingDots size={18} />
|
||||
</MarkerIcon>
|
||||
<MarkerContent style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>
|
||||
{label}
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -363,7 +630,7 @@ function ApprovalRow({
|
||||
onDecide,
|
||||
t,
|
||||
}: {
|
||||
card: ApprovalCard;
|
||||
card: ApprovalSegment;
|
||||
onDecide: (token: string, approved: boolean) => void;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
}) {
|
||||
@@ -418,13 +685,15 @@ function ErrorRow({ message }: { message: string }) {
|
||||
|
||||
function CostRow({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex' }}>
|
||||
<span style={costChipStyle}>{label}</span>
|
||||
</div>
|
||||
<Marker>
|
||||
<MarkerContent style={costChipStyle}>{label}</MarkerContent>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 样式 ──────────────────────────────────────────────────────────────
|
||||
// ── 样式(shadcn Card 消息窗语言,主题 token 自适应浅/深色 —— 用户拍板)──
|
||||
// 实底 + 细边框,不用 backdrop-filter(webview 模糊不了透明窗口背后的桌面,
|
||||
// issue #470 同款结论)。
|
||||
|
||||
const shellStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
@@ -433,30 +702,49 @@ const shellStyle: CSSProperties = {
|
||||
flexDirection: 'column',
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
border: '0.5px solid rgba(0, 0, 0, 0.12)',
|
||||
background: 'rgba(246, 247, 250, 0.88)',
|
||||
boxShadow: '0 18px 44px -18px rgba(15,17,22,.28), 0 0 0 0.5px rgba(255,255,255,.7) inset',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
background: 'var(--ol-surface)',
|
||||
boxShadow: '0 18px 44px -22px rgba(0,0,0,.4)',
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
color: 'var(--ol-ink)',
|
||||
isolation: 'isolate',
|
||||
};
|
||||
|
||||
const toolbarStyle: CSSProperties = {
|
||||
height: 28,
|
||||
const headerStyle: CSSProperties = {
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
borderBottom: '0.5px solid rgba(0, 0, 0, 0.08)',
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.74), rgba(238,240,245,0.58))',
|
||||
boxShadow: '0 1px 0 rgba(255,255,255,.55) inset',
|
||||
backdropFilter: 'blur(18px) saturate(150%)',
|
||||
WebkitBackdropFilter: 'blur(18px) saturate(150%)',
|
||||
gap: 8,
|
||||
padding: '0 10px 0 14px',
|
||||
borderBottom: '0.5px solid var(--ol-line)',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const headerTitleStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ol-ink)',
|
||||
lineHeight: '18px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
};
|
||||
|
||||
/** 状态行:working 时显示「正在操控电脑…」(扫光由 lc-tool-shimmer 提供),
|
||||
空闲时占位保持 header 高度稳定。 */
|
||||
const headerStatusStyle: CSSProperties = {
|
||||
fontSize: 11.5,
|
||||
fontWeight: 500,
|
||||
lineHeight: '16px',
|
||||
color: 'var(--ol-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minHeight: 16,
|
||||
};
|
||||
|
||||
const closeBtnStyle: CSSProperties = {
|
||||
width: 22,
|
||||
height: 22,
|
||||
@@ -472,11 +760,10 @@ const closeBtnStyle: CSSProperties = {
|
||||
transition: 'background 0.16s var(--ol-motion-quick)',
|
||||
};
|
||||
|
||||
// overflow 由 MessageScroller 的内部 viewport 接管,外层只负责占满剩余高度。
|
||||
const scrollStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
@@ -498,24 +785,20 @@ const userBubbleStyle: CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
borderRadius: 14,
|
||||
borderBottomRightRadius: 4,
|
||||
background: 'var(--ol-blue)',
|
||||
color: '#fff',
|
||||
background: 'var(--ol-accent-solid-bg)',
|
||||
boxShadow: '0 2px 10px -6px rgba(37, 99, 235, 0.5)',
|
||||
color: 'var(--ol-accent-solid-ink)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
wordBreak: 'break-word',
|
||||
};
|
||||
|
||||
const assistantBubbleStyle: CSSProperties = {
|
||||
maxWidth: '92%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 14,
|
||||
borderBottomLeftRadius: 4,
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
/** 助手文本:无气泡的文档流。 */
|
||||
const assistantTextStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--ol-ink)',
|
||||
wordBreak: 'break-word',
|
||||
alignSelf: 'flex-start',
|
||||
};
|
||||
|
||||
const caretStyle: CSSProperties = {
|
||||
@@ -523,23 +806,10 @@ const caretStyle: CSSProperties = {
|
||||
width: 6,
|
||||
height: 12,
|
||||
background: 'var(--ol-blue)',
|
||||
marginLeft: 12,
|
||||
animation: 'lc-pulse 0.9s var(--ol-motion-soft) infinite',
|
||||
borderRadius: 1,
|
||||
};
|
||||
|
||||
const toolChipStyle: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ol-ink-2)',
|
||||
background: 'rgba(0,0,0,0.045)',
|
||||
border: '0.5px solid rgba(0,0,0,0.06)',
|
||||
borderRadius: 8,
|
||||
padding: '4px 8px',
|
||||
};
|
||||
|
||||
const costChipStyle: CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
@@ -547,29 +817,21 @@ const costChipStyle: CSSProperties = {
|
||||
fontFamily: 'var(--ol-font-mono)',
|
||||
};
|
||||
|
||||
const dotStyle: CSSProperties = {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--ol-blue)',
|
||||
animation: 'lc-pulse 1.2s var(--ol-motion-soft) infinite',
|
||||
};
|
||||
|
||||
const approvalCardStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
background: 'rgba(220,38,38,0.05)',
|
||||
border: '0.5px solid rgba(220,38,38,0.20)',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '0.5px solid rgba(239, 68, 68, 0.28)',
|
||||
};
|
||||
|
||||
const approvalCmdStyle: CSSProperties = {
|
||||
fontFamily: 'var(--ol-font-mono)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--ol-ink)',
|
||||
background: 'rgba(0,0,0,0.05)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
borderRadius: 6,
|
||||
padding: '5px 8px',
|
||||
wordBreak: 'break-all',
|
||||
@@ -578,9 +840,9 @@ const approvalCmdStyle: CSSProperties = {
|
||||
const approvalRerunWarningStyle: CSSProperties = {
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
color: 'rgb(180,83,9)',
|
||||
background: 'rgba(245,158,11,0.10)',
|
||||
border: '0.5px solid rgba(245,158,11,0.25)',
|
||||
color: 'var(--ol-warn)',
|
||||
background: 'rgba(245, 158, 11, 0.12)',
|
||||
border: '0.5px solid rgba(245, 158, 11, 0.30)',
|
||||
borderRadius: 6,
|
||||
padding: '5px 8px',
|
||||
};
|
||||
@@ -593,8 +855,8 @@ const approveBtnStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'default',
|
||||
background: 'var(--ol-blue)',
|
||||
color: '#fff',
|
||||
background: 'var(--ol-accent-solid-bg)',
|
||||
color: 'var(--ol-accent-solid-ink)',
|
||||
};
|
||||
|
||||
const denyBtnStyle: CSSProperties = {
|
||||
@@ -605,15 +867,58 @@ const denyBtnStyle: CSSProperties = {
|
||||
fontWeight: 600,
|
||||
cursor: 'default',
|
||||
background: 'transparent',
|
||||
border: '0.5px solid rgba(0,0,0,0.14)',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
color: 'var(--ol-ink-2)',
|
||||
};
|
||||
|
||||
const errorRowStyle: CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(220,38,38,0.06)',
|
||||
border: '0.5px solid rgba(220,38,38,0.18)',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '0.5px solid rgba(239, 68, 68, 0.24)',
|
||||
};
|
||||
|
||||
const composerStyle: CSSProperties = {
|
||||
flexShrink: 0,
|
||||
minHeight: COMPOSER_HEIGHT,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 10px',
|
||||
borderTop: '0.5px solid var(--ol-line)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const composerInputStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
resize: 'none',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 10,
|
||||
padding: '9px 12px',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
color: 'var(--ol-ink)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
outline: 'none',
|
||||
maxHeight: 92,
|
||||
};
|
||||
|
||||
const sendButtonStyle: CSSProperties = {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'default',
|
||||
padding: 0,
|
||||
background: 'var(--ol-accent-solid-bg)',
|
||||
color: 'var(--ol-accent-solid-ink)',
|
||||
};
|
||||
|
||||
const globalCss = `
|
||||
@@ -621,26 +926,50 @@ const globalCss = `
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(.94); }
|
||||
}
|
||||
/* 内容进场:工具芯片 / 气泡 / 审批卡出现时柔和淡入上滑,而不是直接闪出。 */
|
||||
/* 内容进场:工具行 / 气泡 / 审批卡出现时柔和淡入上滑,而不是直接闪出。 */
|
||||
@keyframes lc-enter {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* 浮窗整体入场:新会话唤起时上滑浮现(窗口复用,靠 key 重放)。 */
|
||||
@keyframes lc-shell-in {
|
||||
from { opacity: 0; transform: translateY(10px) scale(.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.lc-shell { position: relative; }
|
||||
.lc-bg {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
radial-gradient(120% 80% at 18% 0%, rgba(255,255,255,.72), rgba(255,255,255,0) 58%),
|
||||
linear-gradient(180deg, rgba(248,249,252,.78), rgba(235,238,244,.72));
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
.lc-shell-in { animation: lc-shell-in 0.34s var(--ol-motion-spring, cubic-bezier(0.16,1,0.3,1)) both; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lc-shell-in { animation: none; }
|
||||
}
|
||||
.lc-enter { animation: lc-enter 0.30s var(--ol-motion-soft, cubic-bezier(.16,1,.3,1)) both; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lc-enter { animation: none; }
|
||||
}
|
||||
/* 工具行:纯文字(无图标无框)。进行中扫光,结束停光。 */
|
||||
.lc-tool {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: var(--ol-ink-4);
|
||||
}
|
||||
.lc-tool-shimmer {
|
||||
background-image: linear-gradient(100deg,
|
||||
var(--ol-ink-4) 0%, var(--ol-ink-4) 35%,
|
||||
var(--ol-blue) 50%,
|
||||
var(--ol-ink-4) 65%, var(--ol-ink-4) 100%);
|
||||
background-size: 220% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: lc-shimmer 1.6s linear infinite;
|
||||
}
|
||||
@keyframes lc-shimmer {
|
||||
0% { background-position: 200% center; }
|
||||
100% { background-position: -200% center; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lc-tool-shimmer { animation: none; -webkit-text-fill-color: currentColor; }
|
||||
}
|
||||
.lc-answer p { margin: 0 0 6px; }
|
||||
.lc-answer p:last-child { margin-bottom: 0; }
|
||||
.lc-answer ul,
|
||||
@@ -648,9 +977,9 @@ const globalCss = `
|
||||
.lc-answer li { margin: 2px 0; }
|
||||
.lc-answer code { font-family: var(--ol-font-mono); font-size: 12px;
|
||||
padding: 1px 5px; border-radius: 4px;
|
||||
background: rgba(0,0,0,0.05); }
|
||||
background: var(--ol-surface-2); }
|
||||
.lc-answer pre { margin: 0 0 6px; padding: 8px 10px;
|
||||
border-radius: 8px; background: rgba(0,0,0,0.05);
|
||||
border-radius: 8px; background: var(--ol-surface-2);
|
||||
overflow-x: auto; }
|
||||
.lc-answer pre code { padding: 0; background: transparent; }
|
||||
.lc-answer a { color: var(--ol-blue); text-decoration: none; }
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { SavedToast } from '../components/SavedToast';
|
||||
import { ThinkingDots } from '../components/ThinkingDots';
|
||||
import { GithubLoginModal } from '../components/GithubLoginModal';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import {
|
||||
@@ -436,7 +437,7 @@ export function Marketplace() {
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 999,
|
||||
display: 'inline-grid', placeItems: 'center',
|
||||
background: 'rgba(15,23,42,0.06)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
fontSize: 10, fontWeight: 750,
|
||||
}}>
|
||||
{(currentLogin || '?').slice(0, 1).toUpperCase()}
|
||||
@@ -529,10 +530,21 @@ export function Marketplace() {
|
||||
)}
|
||||
|
||||
{/* 卡片列表 / 我的发布 */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }} className="ol-thinscroll">
|
||||
<div style={{ flex: 1, overflow: 'auto' }} className="ol-thinscroll ol-scroll-fade">
|
||||
{loading && items.length === 0 ? (
|
||||
// 只在没有缓存数据时才显示 loading;有缓存就直接渲染缓存数据,后台 refresh 校准
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ol-ink-4)', fontSize: 13 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
color: 'var(--ol-ink-4)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<ThinkingDots size={18} />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : visibleItems.length === 0 ? (
|
||||
@@ -608,7 +620,18 @@ export function Marketplace() {
|
||||
{selectedId && (
|
||||
<Modal onClose={() => setSelectedId(null)}>
|
||||
{detailLoading || !detail ? (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ol-ink-4)', fontSize: 13 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
color: 'var(--ol-ink-4)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<ThinkingDots size={18} />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -853,7 +876,7 @@ export function Marketplace() {
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 999,
|
||||
display: 'inline-grid', placeItems: 'center',
|
||||
background: currentLogin ? 'rgba(37,99,235,0.14)' : 'rgba(15,23,42,0.06)',
|
||||
background: currentLogin ? 'rgba(37,99,235,0.14)' : 'var(--ol-surface-2)',
|
||||
fontSize: 10, fontWeight: 750,
|
||||
}}>
|
||||
{(currentLogin || '?').slice(0, 1).toUpperCase()}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { formatComboLabel } from '../lib/hotkey';
|
||||
import { getCredentials, listHistory } from '../lib/ipc';
|
||||
import { getActivityStats, getCredentials, listHistory } from '../lib/ipc';
|
||||
import { Heatmap } from '../components/Heatmap';
|
||||
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||
import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
|
||||
import type { ActivityDay, CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
|
||||
@@ -80,6 +81,17 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 年度活动热力图数据(独立于历史内容存储,清空历史不影响)。加载失败仅隐藏卡片。
|
||||
const [activity, setActivity] = useState<ActivityDay[] | null>(null);
|
||||
useEffect(() => {
|
||||
getActivityStats()
|
||||
.then(setActivity)
|
||||
.catch(error => {
|
||||
console.error('[overview] failed to load activity stats', error);
|
||||
setActivity(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshCredentials = useCallback(() => {
|
||||
const requestSeq = credentialsRequestSeq.current + 1;
|
||||
credentialsRequestSeq.current = requestSeq;
|
||||
@@ -196,6 +208,13 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
<Metric icon="bolt" label={t('overview.metricTotal')} value={historyError ? '—' : String(history.length)} trend={historyError ? t('overview.historyLoadError') : t('overview.metricTotalTrend')} accent />
|
||||
</div>
|
||||
|
||||
{/* 年度活动热力图(8starlabs Heatmap 规格):过去一年每日听写次数。
|
||||
数据来自独立的 activity 计数存储,与历史保留策略解耦;
|
||||
可在设置 → 通用 → 外观 里单独关闭。 */}
|
||||
{prefs?.showOverviewActivityHeatmap !== false && activity && activity.length > 0 && (
|
||||
<ActivityHeatmapCard activity={activity} />
|
||||
)}
|
||||
|
||||
{/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。
|
||||
只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。
|
||||
issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */}
|
||||
@@ -295,6 +314,51 @@ function ProviderCard({ kind, name, subname, status }: ProviderCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 年度活动热力图卡:过去 365 天每日听写次数。月份/星期/日期标签用 Intl 按当前语言生成。 */
|
||||
function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { endDate, startDate, data, labels } = useMemo(() => {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - 364);
|
||||
const lang = i18n.language || 'en';
|
||||
const monthFormat = new Intl.DateTimeFormat(lang, { month: 'short' });
|
||||
const dayFormat = new Intl.DateTimeFormat(lang, { weekday: 'short' });
|
||||
const dateFormat = new Intl.DateTimeFormat(lang, { dateStyle: 'medium' });
|
||||
const anchor = new Date(2026, 0, 4); // 周日
|
||||
return {
|
||||
endDate: end,
|
||||
startDate: start,
|
||||
data: activity.map(day => ({ date: day.date, value: day.count })),
|
||||
labels: {
|
||||
months: Array.from({ length: 12 }, (_, m) => monthFormat.format(new Date(2026, m, 1))),
|
||||
days: Array.from({ length: 7 }, (_, d) => {
|
||||
const date = new Date(anchor);
|
||||
date.setDate(anchor.getDate() + d);
|
||||
return dayFormat.format(date);
|
||||
}),
|
||||
date: (date: Date) => dateFormat.format(date),
|
||||
},
|
||||
};
|
||||
}, [activity, i18n.language]);
|
||||
return (
|
||||
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>
|
||||
{t('overview.activityTitle')}
|
||||
</span>
|
||||
<Heatmap
|
||||
data={data}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
monthLabels={labels.months}
|
||||
dayLabels={labels.days}
|
||||
dateDisplay={labels.date}
|
||||
valueDisplay={count => t('overview.activityCount', { count })}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,6 @@ import type { PolishMode, StylePack, StylePackExample, StylePackRuntimeDiagnosti
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { SavedToast, type SaveToastState } from '../components/SavedToast';
|
||||
import { MarketplaceModal } from '../components/MarketplaceModal';
|
||||
|
||||
type BusyAction =
|
||||
| 'loading'
|
||||
@@ -141,7 +140,6 @@ export function Style() {
|
||||
const editorCloseTimer = useRef<number | null>(null);
|
||||
const [runtimePreview, setRuntimePreview] = useState<StylePackRuntimeDiagnostics | null>(null);
|
||||
const [runtimePreviewError, setRuntimePreviewError] = useState<string | null>(null);
|
||||
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (statusTimer.current !== null) window.clearTimeout(statusTimer.current);
|
||||
@@ -523,10 +521,7 @@ export function Style() {
|
||||
desc={t('style.pack.desc')}
|
||||
right={(
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 40 }}>
|
||||
{/* 风格市场入口:放在 刷新 左边(按用户需求)。点击 → 全屏弹框承载 <Marketplace />。*/}
|
||||
<Btn variant="ghost" icon="cloud" onClick={() => setMarketplaceOpen(true)}>
|
||||
{t('style.pack.marketplaceBtn')}
|
||||
</Btn>
|
||||
{/* 风格市场入口已移到侧栏「风格」展开组(用户拍板);此处不再放按钮。 */}
|
||||
<Btn variant="ghost" icon="refresh" onClick={() => void loadPacks(selectedId)} disabled={busy === 'loading'}>
|
||||
{t('common.refresh')}
|
||||
</Btn>
|
||||
@@ -541,16 +536,6 @@ export function Style() {
|
||||
淡蓝 pill 只闪现 0.8s,不长期遮挡按钮。 */}
|
||||
<SavedToast saveState={saveState} message={saveMessage} />
|
||||
|
||||
{marketplaceOpen && (
|
||||
<MarketplaceModal
|
||||
onClose={() => {
|
||||
setMarketplaceOpen(false);
|
||||
// 用户可能在 modal 内安装过远端 pack;关闭后刷新本地列表,避免新装的看不到。
|
||||
void loadPacks();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card padding={0} style={{ overflow: 'hidden', flex: '1 1 0', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
@@ -666,7 +651,7 @@ export function Style() {
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 12,
|
||||
display: 'grid', placeItems: 'center',
|
||||
background: pack.active ? 'rgba(37,99,235,0.12)' : 'rgba(15,23,42,0.05)',
|
||||
background: pack.active ? 'rgba(37,99,235,0.12)' : 'var(--ol-surface-2)',
|
||||
color: pack.active ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
@@ -771,7 +756,7 @@ export function Style() {
|
||||
style={{
|
||||
width: 44, height: 44, borderRadius: 999,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(15,23,42,0.04)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
color: 'var(--ol-ink-2)',
|
||||
}}
|
||||
>
|
||||
@@ -798,7 +783,7 @@ export function Style() {
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15,17,22,0.32)',
|
||||
background: 'var(--ol-overlay-bg)',
|
||||
backdropFilter: 'blur(8px) saturate(140%)',
|
||||
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
|
||||
zIndex: 40,
|
||||
@@ -828,7 +813,7 @@ export function Style() {
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 24px 80px rgba(15,23,42,0.22)',
|
||||
boxShadow: 'var(--ol-shadow-xl)',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
@@ -992,7 +977,7 @@ export function Style() {
|
||||
padding={16}
|
||||
style={{
|
||||
background: 'var(--ol-style-subtle-bg)',
|
||||
border: '0.5px solid rgba(148,163,184,0.24)',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
@@ -1130,7 +1115,7 @@ export function Style() {
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: '0.5px solid rgba(148,163,184,0.22)',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
background: 'var(--ol-style-subtle-bg)',
|
||||
padding: 14,
|
||||
}}
|
||||
@@ -1182,7 +1167,7 @@ function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '0.5px solid rgba(148,163,184,0.2)',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
background: 'var(--ol-style-input-bg)',
|
||||
padding: '10px 12px',
|
||||
}}
|
||||
@@ -1219,7 +1204,7 @@ function DirectiveRow({
|
||||
gap: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '0.5px solid rgba(148,163,184,0.2)',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
background: 'var(--ol-style-input-bg)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function LocalModelSection() {
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.32)',
|
||||
background: 'var(--ol-overlay-bg)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
|
||||
@@ -840,7 +840,7 @@ const miniBtnStyle: CSSProperties = {
|
||||
height: 32, padding: '0 12px',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 8, background: 'var(--ol-surface)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
||||
color: 'var(--ol-ink-2)', cursor: 'default', flexShrink: 0,
|
||||
fontSize: 12.5, fontWeight: 500, letterSpacing: '0.01em',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.16s var(--ol-motion-quick)',
|
||||
@@ -850,7 +850,7 @@ const iconBtnStyle: CSSProperties = {
|
||||
width: 32, height: 32,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 8, background: 'var(--ol-surface)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--ol-ink-3)', cursor: 'default', flexShrink: 0,
|
||||
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
|
||||
|
||||
@@ -249,7 +249,7 @@ export function RemoteInputSection() {
|
||||
)}
|
||||
|
||||
{enabled && startError != null && (
|
||||
<div style={{ fontSize: 12, color: '#d9534f', marginTop: 8 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-red, #ef4444)', marginTop: 8 }}>
|
||||
{startError.reason === 'port-in-use'
|
||||
? t('settings.remoteInput.portInUse', { port: startError.port })
|
||||
: t('settings.remoteInput.startError', { reason: startError.reason })}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import { SelectLite } from '../../components/ui/SelectLite';
|
||||
import { Card } from '../_atoms';
|
||||
import { SettingRow } from './shared';
|
||||
import { SettingRow, Toggle } from './shared';
|
||||
import {
|
||||
readThemePreference,
|
||||
setThemePreference,
|
||||
@@ -53,6 +53,17 @@ export function ThemeSection() {
|
||||
style={{ maxWidth: 220, minWidth: 200 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
{/* 概览页年度活动热力图开关:关闭只隐藏卡片,活动计数照常记录。 */}
|
||||
{prefs && (
|
||||
<SettingRow label={t('settings.theme.activityHeatmapLabel')}>
|
||||
<Toggle
|
||||
on={prefs.showOverviewActivityHeatmap !== false}
|
||||
onToggle={next =>
|
||||
void updatePrefs(current => ({ ...current, showOverviewActivityHeatmap: next }))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -17,18 +17,25 @@ import { CodingAgentSection } from './CodingAgentSection';
|
||||
import { ClaudeConsoleSection } from './ClaudeConsoleSection';
|
||||
import { BetaChannelSection } from './BetaChannelSection';
|
||||
import { AutoUpdateSection } from './AutoUpdateSection';
|
||||
import { AboutSection } from './AboutSection';
|
||||
import { detectOS } from '../../components/WindowChrome';
|
||||
import { getPlatformCapabilities } from '../../lib/platform';
|
||||
import type { PlatformCapabilities } from '../../lib/types';
|
||||
|
||||
// 通用:录音与输入 · 快捷键 · 语言。
|
||||
export function GeneralTab() {
|
||||
// 各 tab 共用的平台能力查询(决定桌面/移动、是否支持热键与自动更新等 gating)。
|
||||
function usePlatformCaps(): PlatformCapabilities | null {
|
||||
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getPlatformCapabilities().then(setPlatformCaps);
|
||||
}, []);
|
||||
|
||||
return platformCaps;
|
||||
}
|
||||
|
||||
// 通用:录音与输入 · 快捷键 · 主题 · 语言。
|
||||
export function GeneralTab() {
|
||||
const platformCaps = usePlatformCaps();
|
||||
const showDesktopShortcuts = platformCaps?.supportsDesktopHotkey === true;
|
||||
|
||||
return (
|
||||
@@ -41,11 +48,17 @@ export function GeneralTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// 服务:AI 提供商 · 扩展市场。
|
||||
// 服务:AI 提供商 · 本地模型 · 扩展市场。
|
||||
// 本地模型是「语音识别由谁提供」的一种答案,和云端提供商属同一决策,
|
||||
// 不再藏进「高级」。
|
||||
export function ServicesTab() {
|
||||
const platformCaps = usePlatformCaps();
|
||||
const showLocalModel = platformCaps?.platform === 'desktop';
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProvidersSection />
|
||||
{showLocalModel && <LocalModelSection />}
|
||||
<MarketplaceSection />
|
||||
</>
|
||||
);
|
||||
@@ -84,25 +97,32 @@ export function PrivacyTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// 高级:本地模型 · 调试工具 · 加入 Beta 渠道(固定在最下面)。
|
||||
// 高级:只留真正的实验性/开发者功能 —— Less Computer · Claude 控制台 · 调试工具。
|
||||
// (本地模型移入「服务」、更新相关移入「关于」,这个 tab 不再是杂物抽屉。)
|
||||
export function AdvancedTab() {
|
||||
const os = detectOS();
|
||||
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getPlatformCapabilities().then(setPlatformCaps);
|
||||
}, []);
|
||||
|
||||
const platformCaps = usePlatformCaps();
|
||||
const showDesktopAdvanced = platformCaps?.platform === 'desktop';
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDesktopAdvanced && <LocalModelSection />}
|
||||
{showDesktopAdvanced && <DebugToolsSection />}
|
||||
{showDesktopAdvanced && os !== 'win' && <CodingAgentSection />}
|
||||
{showDesktopAdvanced && os !== 'win' && <ClaudeConsoleSection />}
|
||||
{platformCaps?.supportsAutoUpdate === true && <BetaChannelSection />}
|
||||
{platformCaps?.supportsAutoUpdate === true && <AutoUpdateSection />}
|
||||
{showDesktopAdvanced && <DebugToolsSection />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 关于:版本信息 · 更新渠道 · 自动更新 —— 「我用的是什么版本、怎么更新」归一处。
|
||||
export function AboutTab() {
|
||||
const platformCaps = usePlatformCaps();
|
||||
const showUpdateControls = platformCaps?.supportsAutoUpdate === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AboutSection />
|
||||
{showUpdateControls && <BetaChannelSection />}
|
||||
{showUpdateControls && <AutoUpdateSection />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type AppTab =
|
||||
| 'history'
|
||||
| 'vocab'
|
||||
| 'style'
|
||||
| 'marketplace'
|
||||
| 'translation'
|
||||
| 'selectionAsk'
|
||||
| 'localAsr';
|
||||
|
||||
@@ -144,6 +144,12 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 风格市场全高抽屉从右缘滑入(MarketplaceModal 引用此名)。 */
|
||||
@keyframes ol-drawer-in-right {
|
||||
from { opacity: 0; transform: translateX(48px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ── 假毛玻璃面(qa 追问框 / capsule 语音胶囊等透明浮窗)─────────────────────
|
||||
webview 拿不到透明窗口背后的桌面像素 —— CSS backdrop-filter 模糊不了桌面,
|
||||
是 Tauri 已知的上游限制(issue #2827 / #10064 / #6876)。所以这里**不做
|
||||
@@ -222,3 +228,76 @@ html[data-ol-platform="linux"] .ol-capsule-pill,
|
||||
html[data-ol-platform="android"] .ol-capsule-pill {
|
||||
background: var(--ol-capsule-pill-bg) !important;
|
||||
}
|
||||
|
||||
/* ── 滚动边缘渐隐(.ol-scroll-fade)──────────────────────────────────────────
|
||||
参照 shadcn scroll-fade 工具的行为,纯 CSS scroll-driven animations 实现:
|
||||
静止在顶部时顶缘清晰、底缘 28px 渐隐提示「下面还有内容」;滚动中双边渐隐;
|
||||
滚到底部时底缘恢复清晰。两个注册的 <length> 自定义属性充当 mask 渐变里的
|
||||
动画插值目标(初始值 = 静止在顶部的状态)。 */
|
||||
@property --ol-sf-t {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --ol-sf-b {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 28px;
|
||||
}
|
||||
|
||||
/* 三层 mask 取交集:顶缘渐隐层 ∩ 底缘渐隐层 ∩ 全黑兜底层。
|
||||
-webkit-mask-composite: source-in 等价于标准 mask-composite: intersect;
|
||||
两者都写、标准属性在后 —— 新引擎用标准值,老 WebKit 走前缀值。 */
|
||||
.ol-scroll-fade {
|
||||
-webkit-mask-image:
|
||||
linear-gradient(to bottom, transparent, black var(--ol-sf-t)),
|
||||
linear-gradient(to top, transparent, black var(--ol-sf-b)),
|
||||
linear-gradient(black, black);
|
||||
mask-image:
|
||||
linear-gradient(to bottom, transparent, black var(--ol-sf-t)),
|
||||
linear-gradient(to top, transparent, black var(--ol-sf-b)),
|
||||
linear-gradient(black, black);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll(self y)) {
|
||||
/* 滚动驱动:开头 96px 行程里顶缘渐隐 0 → 28px,末尾 96px 行程里底缘
|
||||
渐隐 28px → 0,fill both 在行程外保持端点值。
|
||||
注意:animation-timeline / animation-range 必须写在 animation 简写之后,
|
||||
简写会把它们重置回初始值。
|
||||
已知限制:内容不溢出时 scroll(self y) 时间线不激活,自定义属性回落到
|
||||
初始值 —— 底缘仍有 28px 渐隐轻微作用在不可滚动容器的末端(通常落在
|
||||
内边距上几乎不可见),与下方静态降级的限制一致,接受。 */
|
||||
.ol-scroll-fade {
|
||||
animation:
|
||||
ol-sf-top linear both,
|
||||
ol-sf-bottom linear both;
|
||||
animation-timeline: scroll(self y);
|
||||
animation-range: 0 96px, calc(100% - 96px) 100%;
|
||||
}
|
||||
|
||||
@keyframes ol-sf-top {
|
||||
from { --ol-sf-t: 0px; }
|
||||
to { --ol-sf-t: 28px; }
|
||||
}
|
||||
|
||||
@keyframes ol-sf-bottom {
|
||||
from { --ol-sf-b: 28px; }
|
||||
to { --ol-sf-b: 0px; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 降级(macOS 现行 WKWebView 尚无 scroll-driven animations):静态双边渐隐,
|
||||
不随滚动位置变化;单层 mask 无需分层合成,把 composite 重置回默认,
|
||||
规避老 WebKit 单层 + source-in 的兼容怪癖。限制同上:不可滚动的容器
|
||||
两缘也会有 28px 轻微渐隐。 */
|
||||
@supports not (animation-timeline: scroll(self y)) {
|
||||
.ol-scroll-fade {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 28px, black calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(to bottom, transparent, black 28px, black calc(100% - 28px), transparent);
|
||||
-webkit-mask-composite: source-over;
|
||||
mask-composite: add;
|
||||
}
|
||||
}
|
||||
@@ -13,34 +13,32 @@
|
||||
--ol-control-radius: 8px;
|
||||
--ol-bubble-radius: var(--ol-r-lg);
|
||||
--ol-input-ink: var(--ol-ink);
|
||||
/* 白底哲学:霜感玻璃改为近实色,纯白渐变到 zinc-50。 */
|
||||
--ol-frost-bg:
|
||||
radial-gradient(135% 86% at 15% -10%,
|
||||
rgba(255, 255, 255, 0.97) 0%,
|
||||
rgba(255, 255, 255, 0) 56%),
|
||||
linear-gradient(161deg,
|
||||
rgba(255, 255, 255, 0.93) 0%,
|
||||
rgba(244, 247, 252, 0.88) 100%);
|
||||
/* Shared palette */
|
||||
#ffffff 0%,
|
||||
#fafafa 100%);
|
||||
/* Shared palette(shadcn zinc) */
|
||||
--ol-white: #ffffff;
|
||||
--ol-canvas: #f7f7f8;
|
||||
--ol-canvas: #ffffff;
|
||||
--ol-surface: #ffffff;
|
||||
--ol-surface-2: #fafafa;
|
||||
--ol-surface-2: #f4f4f5;
|
||||
--ol-surface-solid: #ffffff;
|
||||
--ol-line: rgba(0, 0, 0, 0.08);
|
||||
--ol-line-strong: rgba(0, 0, 0, 0.14);
|
||||
--ol-line-soft: rgba(0, 0, 0, 0.05);
|
||||
--ol-line: #e4e4e7;
|
||||
--ol-line-strong: #d4d4d8;
|
||||
--ol-line-soft: #f4f4f5;
|
||||
|
||||
--ol-bg-base: #f3f4f6;
|
||||
--ol-bg-elevated: #fbfbfc;
|
||||
--ol-bg-base: #fafafa;
|
||||
--ol-bg-elevated: #ffffff;
|
||||
|
||||
/* Ink */
|
||||
--ol-ink: #0a0a0b;
|
||||
--ol-ink-2: #2a2a2d;
|
||||
--ol-ink-3: rgba(10, 10, 11, 0.62);
|
||||
/* AA 修复:曾把 alpha 从 0.58 降到 0.42,浅色 surface 上的弱化文字跌到
|
||||
2.88:1(< WCAG AA 4.5:1)。恢复为 0.58(≈5.1:1,仍比 --ol-ink-3 浅)。 */
|
||||
--ol-ink-4: rgba(10, 10, 11, 0.58);
|
||||
--ol-ink-5: rgba(10, 10, 11, 0.24);
|
||||
/* Ink(zinc 阶梯) */
|
||||
--ol-ink: #09090b;
|
||||
--ol-ink-2: #3f3f46;
|
||||
--ol-ink-3: #71717a;
|
||||
/* zinc 规格:ink-4 取 zinc-400(对白底约 2.6:1),仅用于占位符/装饰性弱文字;
|
||||
需要满足 AA 的弱化正文请用 ink-3(zinc-500,对白底约 4.7:1)及以上。 */
|
||||
--ol-ink-4: #a1a1aa;
|
||||
--ol-ink-5: #d4d4d8;
|
||||
|
||||
/* Blue accent */
|
||||
--ol-blue: #2563eb;
|
||||
@@ -49,10 +47,10 @@
|
||||
--ol-blue-ring: rgba(37, 99, 235, 0.22);
|
||||
--ol-on-accent: #ffffff;
|
||||
|
||||
/* Shared glass */
|
||||
--ol-glass-bg: rgba(255, 255, 255, 0.62);
|
||||
--ol-glass-bg-strong: rgba(255, 255, 255, 0.78);
|
||||
--ol-glass-border: rgba(255, 255, 255, 0.7);
|
||||
/* Shared glass(近实色:透感基本关闭,保留 token 结构) */
|
||||
--ol-glass-bg: rgba(255, 255, 255, 0.94);
|
||||
--ol-glass-bg-strong: rgba(255, 255, 255, 0.97);
|
||||
--ol-glass-border: #e4e4e7;
|
||||
--ol-glass-blur: 20px;
|
||||
--ol-glass-blur-strong: 36px;
|
||||
|
||||
@@ -64,11 +62,11 @@
|
||||
--ol-motion-soft: cubic-bezier(0.22, 0.8, 0.22, 1);
|
||||
--ol-motion-quick: cubic-bezier(0.2, 0, 0, 1);
|
||||
|
||||
/* Shadows */
|
||||
--ol-shadow-sm: 0 1px 2px rgba(15, 17, 22, 0.04), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
|
||||
--ol-shadow-md: 0 1px 2px rgba(15, 17, 22, 0.05), 0 6px 24px -12px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
|
||||
--ol-shadow-lg: 0 20px 60px -20px rgba(15, 17, 22, 0.18), 0 8px 32px -16px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
|
||||
--ol-shadow-xl: 0 40px 120px -40px rgba(15, 17, 22, 0.30), 0 24px 60px -24px rgba(15, 17, 22, 0.15), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
|
||||
/* Shadows(shadcn 弱阴影:模糊与 alpha 约砍半) */
|
||||
--ol-shadow-sm: 0 1px 1px rgba(15, 17, 22, 0.02), 0 0 0 0.5px rgba(0, 0, 0, 0.02);
|
||||
--ol-shadow-md: 0 1px 1px rgba(15, 17, 22, 0.03), 0 6px 12px -6px rgba(15, 17, 22, 0.05), 0 0 0 0.5px rgba(0, 0, 0, 0.02);
|
||||
--ol-shadow-lg: 0 20px 30px -10px rgba(15, 17, 22, 0.09), 0 8px 16px -8px rgba(15, 17, 22, 0.05), 0 0 0 0.5px rgba(0, 0, 0, 0.03);
|
||||
--ol-shadow-xl: 0 40px 60px -20px rgba(15, 17, 22, 0.15), 0 24px 30px -12px rgba(15, 17, 22, 0.08), 0 0 0 0.5px rgba(0, 0, 0, 0.03);
|
||||
|
||||
/* Radii */
|
||||
--ol-shell-radius: 32px;
|
||||
@@ -94,31 +92,27 @@
|
||||
--ol-warn-soft: #fff7ed;
|
||||
--ol-err: #dc2626;
|
||||
|
||||
/* Theme-aware shell and control tokens */
|
||||
/* Theme-aware shell and control tokens(白/实底:去掉彩色氛围渐变) */
|
||||
--ol-app-shell-bg:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 38%),
|
||||
radial-gradient(circle at 86% 18%, rgba(47, 109, 246, 0.08), rgba(47, 109, 246, 0) 30%),
|
||||
linear-gradient(180deg, var(--ol-bg-elevated) 0%, var(--ol-bg-base) 100%);
|
||||
--ol-window-bg:
|
||||
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 60%),
|
||||
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
|
||||
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%);
|
||||
--ol-window-border: rgba(0, 0, 0, 0.10);
|
||||
--ol-linux-titlebar-bg: rgba(245, 245, 247, 0.85);
|
||||
--ol-linux-titlebar-border: rgba(0, 0, 0, 0.08);
|
||||
linear-gradient(180deg, #ffffff 0%, #fafafa 100%);
|
||||
--ol-window-border: #e4e4e7;
|
||||
--ol-linux-titlebar-bg: rgba(250, 250, 250, 0.92);
|
||||
--ol-linux-titlebar-border: #e4e4e7;
|
||||
|
||||
--ol-panel-bg: rgba(255, 255, 255, 0.72);
|
||||
--ol-panel-border: rgba(255, 255, 255, 0.58);
|
||||
--ol-panel-bg: rgba(255, 255, 255, 0.96);
|
||||
--ol-panel-border: #e4e4e7;
|
||||
--ol-panel-shadow: 0 24px 80px -32px rgba(15, 23, 42, 0.24), 0 12px 36px -18px rgba(15, 23, 42, 0.12);
|
||||
--ol-card-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(252, 252, 253, 0.82));
|
||||
--ol-card-border: rgba(255, 255, 255, 0.74);
|
||||
--ol-card-bg: #ffffff;
|
||||
--ol-card-border: #e4e4e7;
|
||||
--ol-card-shadow: 0 10px 30px -18px rgba(15, 23, 42, 0.16), 0 0 0 0.5px rgba(255, 255, 255, 0.55) inset;
|
||||
/* 玻璃面板背景(半透明,配 backdrop-filter);FloatingShell 主区非不透明分支用,
|
||||
light/dark 取值不同需保持主题感知。 */
|
||||
--ol-panel-glass-bg: rgba(255, 255, 255, 0.72);
|
||||
--ol-panel-glass-bg: rgba(255, 255, 255, 0.94);
|
||||
|
||||
--ol-sidebar-bg: linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.12));
|
||||
--ol-sidebar-border: rgba(255,255,255,0.38);
|
||||
--ol-sidebar-bg: #fafafa;
|
||||
--ol-sidebar-border: #e4e4e7;
|
||||
--ol-sidebar-brand-bg: linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0.36));
|
||||
--ol-sidebar-brand-border: rgba(255,255,255,0.50);
|
||||
--ol-sidebar-pill-bg: linear-gradient(180deg, rgba(255,255,255,0.84), rgba(255,255,255,0.58));
|
||||
@@ -131,10 +125,10 @@
|
||||
--ol-sidebar-settings-active-bg: linear-gradient(180deg, rgba(255,255,255,0.82), rgba(255,255,255,0.48));
|
||||
--ol-nav-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
|
||||
--ol-settings-rail-bg: linear-gradient(180deg, rgba(255,255,255,0.58), rgba(246,248,252,0.72));
|
||||
--ol-settings-rail-bg: linear-gradient(180deg, rgba(252,252,253,0.96), rgba(250,250,250,0.96));
|
||||
--ol-settings-rail-border: rgba(255,255,255,0.52);
|
||||
--ol-settings-links-border: rgba(255,255,255,0.46);
|
||||
--ol-settings-content-bg: linear-gradient(180deg, rgba(255,255,255,0.56), rgba(249,250,252,0.74));
|
||||
--ol-settings-content-bg: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(252,252,253,0.96));
|
||||
--ol-settings-close-bg: rgba(255,255,255,0.38);
|
||||
--ol-settings-close-hover-bg: rgba(255,255,255,0.72);
|
||||
|
||||
@@ -145,23 +139,23 @@
|
||||
--ol-control-elevated: var(--ol-surface-2);
|
||||
--ol-control-shadow: 0 10px 20px -18px rgba(15,23,42,0.2);
|
||||
--ol-pill-bg: linear-gradient(180deg, rgba(255,255,255,0.84), rgba(245,247,250,0.68));
|
||||
--ol-pill-border: rgba(148,163,184,0.22);
|
||||
--ol-pill-border: rgba(161,161,170,0.22);
|
||||
--ol-pill-shadow: inset 0 1px 0 0 rgba(255,255,255,0.72), 0 10px 20px -18px rgba(15,23,42,0.18);
|
||||
--ol-pill-blue-bg: rgba(96,165,250,0.14);
|
||||
--ol-pill-blue-border: rgba(96,165,250,0.24);
|
||||
--ol-pill-ok-bg: rgba(74,222,128,0.14);
|
||||
--ol-pill-ok-border: rgba(74,222,128,0.22);
|
||||
--ol-pill-dark-bg: linear-gradient(180deg, rgba(28,34,45,0.92), rgba(18,23,31,0.88));
|
||||
--ol-pill-dark-bg: linear-gradient(180deg, rgba(39,39,42,0.92), rgba(24,24,27,0.88));
|
||||
--ol-pill-dark-border: rgba(15,23,42,0.10);
|
||||
--ol-pill-dark-shadow: 0 12px 20px -18px rgba(15,23,42,0.22);
|
||||
--ol-pill-selected-bg: #111827;
|
||||
--ol-pill-selected-bg: #18181b;
|
||||
--ol-pill-selected-ink: #ffffff;
|
||||
--ol-pill-selected-border: #111827;
|
||||
--ol-pill-selected-border: #18181b;
|
||||
--ol-segmented-bg: rgba(0,0,0,0.04);
|
||||
--ol-segmented-active-bg: #ffffff;
|
||||
--ol-segmented-active-shadow: 0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06);
|
||||
--ol-select-trigger-bg: var(--ol-control-solid);
|
||||
--ol-select-popover-bg: rgba(252, 252, 254, 0.96);
|
||||
--ol-select-popover-bg: rgba(255, 255, 255, 0.97);
|
||||
--ol-select-popover-border: rgba(0, 0, 0, 0.10);
|
||||
--ol-select-popover-shadow: 0 12px 30px -10px rgba(15, 17, 22, 0.25), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
|
||||
--ol-select-option-hover-bg: rgba(37, 99, 235, 0.10);
|
||||
@@ -178,10 +172,10 @@
|
||||
--ol-style-card-border-active: rgba(96,165,250,0.82);
|
||||
--ol-style-card-shadow: 0 24px 48px -34px rgba(15, 23, 42, 0.18), inset 0 1px 0 0 rgba(255,255,255,0.65);
|
||||
--ol-style-card-shadow-active: 0 24px 56px -28px rgba(37,99,235,0.28), 0 0 0 1px rgba(96,165,250,0.78), 0 0 0 4px rgba(96,165,250,0.16), inset 0 1px 0 0 rgba(255,255,255,0.72);
|
||||
--ol-style-card-ink: #0f172a;
|
||||
--ol-style-card-ink-2: rgba(15, 23, 42, 0.82);
|
||||
--ol-style-card-ink-3: rgba(15, 23, 42, 0.56);
|
||||
--ol-style-card-icon-bg: rgba(226,232,240,0.62);
|
||||
--ol-style-card-ink: #09090b;
|
||||
--ol-style-card-ink-2: rgba(9, 9, 11, 0.82);
|
||||
--ol-style-card-ink-3: rgba(9, 9, 11, 0.56);
|
||||
--ol-style-card-icon-bg: rgba(228,228,231,0.62);
|
||||
--ol-style-card-icon-bg-active: rgba(96,165,250,0.14);
|
||||
--ol-style-delete-bg: rgba(255,255,255,0.56);
|
||||
--ol-style-delete-border: rgba(248,113,113,0.36);
|
||||
@@ -222,31 +216,31 @@
|
||||
--ol-primary-solid-ink: #f8fbff;
|
||||
--ol-danger-solid-bg: #ef4444;
|
||||
--ol-danger-solid-ink: #ffffff;
|
||||
--ol-input-ink: #f4f7fb;
|
||||
--ol-input-ink: #fafafa;
|
||||
--ol-frost-bg:
|
||||
linear-gradient(161deg,
|
||||
rgba(24, 30, 39, 0.96) 0%,
|
||||
rgba(16, 19, 25, 0.93) 100%);
|
||||
rgba(24, 24, 27, 0.97) 0%,
|
||||
rgba(20, 20, 23, 0.96) 100%);
|
||||
|
||||
--ol-white: #151a21;
|
||||
--ol-canvas: #0d1117;
|
||||
--ol-surface: #171d25;
|
||||
--ol-surface-2: #1d2530;
|
||||
--ol-surface-solid: #1b222c;
|
||||
--ol-line: rgba(226, 232, 240, 0.10);
|
||||
--ol-line-strong: rgba(226, 232, 240, 0.18);
|
||||
--ol-line-soft: rgba(226, 232, 240, 0.07);
|
||||
--ol-white: #1c1c1f;
|
||||
--ol-canvas: #0c0c0e;
|
||||
--ol-surface: #1c1c1f;
|
||||
--ol-surface-2: #2a2a2e;
|
||||
--ol-surface-solid: #18181b;
|
||||
--ol-line: #27272a;
|
||||
--ol-line-strong: #3f3f46;
|
||||
--ol-line-soft: #1f1f23;
|
||||
|
||||
--ol-bg-base: #0b0f15;
|
||||
--ol-bg-elevated: #111720;
|
||||
/* 玻璃面板背景暗色取值。 */
|
||||
--ol-panel-glass-bg: rgba(21, 28, 38, 0.78);
|
||||
--ol-bg-base: #09090b;
|
||||
--ol-bg-elevated: #141417;
|
||||
/* 玻璃面板背景暗色取值(近实色)。 */
|
||||
--ol-panel-glass-bg: rgba(24, 24, 27, 0.96);
|
||||
|
||||
--ol-ink: #f6f8fb;
|
||||
--ol-ink-2: #dce3ee;
|
||||
--ol-ink-3: rgba(246, 248, 251, 0.76);
|
||||
--ol-ink-4: rgba(246, 248, 251, 0.56);
|
||||
--ol-ink-5: rgba(246, 248, 251, 0.28);
|
||||
--ol-ink: #fafafa;
|
||||
--ol-ink-2: #d4d4d8;
|
||||
--ol-ink-3: #a1a1aa;
|
||||
--ol-ink-4: #71717a;
|
||||
--ol-ink-5: #3f3f46;
|
||||
|
||||
--ol-blue: #74b7ff;
|
||||
--ol-blue-hover: #93c5fd;
|
||||
@@ -254,14 +248,14 @@
|
||||
--ol-blue-ring: rgba(116, 183, 255, 0.34);
|
||||
--ol-on-accent: #f8fbff;
|
||||
|
||||
--ol-glass-bg: rgba(18, 24, 33, 0.70);
|
||||
--ol-glass-bg-strong: rgba(22, 29, 39, 0.86);
|
||||
--ol-glass-border: rgba(226, 232, 240, 0.12);
|
||||
--ol-glass-bg: rgba(24, 24, 27, 0.94);
|
||||
--ol-glass-bg-strong: rgba(24, 24, 27, 0.97);
|
||||
--ol-glass-border: #27272a;
|
||||
|
||||
--ol-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.34), 0 0 0 0.5px rgba(226, 232, 240, 0.05);
|
||||
--ol-shadow-md: 0 12px 34px -22px rgba(0, 0, 0, 0.50), 0 0 0 0.5px rgba(226, 232, 240, 0.06);
|
||||
--ol-shadow-lg: 0 26px 80px -34px rgba(0, 0, 0, 0.66), 0 0 0 0.5px rgba(226, 232, 240, 0.06);
|
||||
--ol-shadow-xl: 0 54px 130px -58px rgba(0, 0, 0, 0.78), 0 0 0 0.5px rgba(226, 232, 240, 0.08);
|
||||
--ol-shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.17), 0 0 0 0.5px rgba(226, 232, 240, 0.03);
|
||||
--ol-shadow-md: 0 12px 17px -11px rgba(0, 0, 0, 0.25), 0 0 0 0.5px rgba(226, 232, 240, 0.03);
|
||||
--ol-shadow-lg: 0 26px 40px -17px rgba(0, 0, 0, 0.33), 0 0 0 0.5px rgba(226, 232, 240, 0.03);
|
||||
--ol-shadow-xl: 0 54px 65px -29px rgba(0, 0, 0, 0.39), 0 0 0 0.5px rgba(226, 232, 240, 0.04);
|
||||
|
||||
--ol-ok: #4ade80;
|
||||
--ol-ok-soft: rgba(74, 222, 128, 0.14);
|
||||
@@ -270,46 +264,46 @@
|
||||
--ol-err: #f87171;
|
||||
|
||||
--ol-app-shell-bg:
|
||||
linear-gradient(145deg, rgba(19, 26, 36, 0.96) 0%, rgba(11, 15, 21, 0.98) 46%, rgba(9, 12, 17, 1) 100%);
|
||||
linear-gradient(180deg, #141417 0%, #09090b 100%);
|
||||
--ol-window-bg:
|
||||
linear-gradient(180deg, rgba(25, 32, 42, 0.99) 0%, rgba(15, 20, 28, 0.99) 100%);
|
||||
--ol-window-border: rgba(226,232,240,0.11);
|
||||
--ol-linux-titlebar-bg: rgba(16, 22, 31, 0.92);
|
||||
--ol-linux-titlebar-border: rgba(226,232,240,0.09);
|
||||
linear-gradient(180deg, #18181b 0%, #09090b 100%);
|
||||
--ol-window-border: #27272a;
|
||||
--ol-linux-titlebar-bg: rgba(20, 20, 23, 0.92);
|
||||
--ol-linux-titlebar-border: #27272a;
|
||||
|
||||
--ol-panel-bg: linear-gradient(180deg, rgba(25, 32, 42, 0.96), rgba(17, 23, 31, 0.95));
|
||||
--ol-panel-border: rgba(226,232,240,0.10);
|
||||
--ol-panel-bg: linear-gradient(180deg, rgba(24, 24, 27, 0.96), rgba(20, 20, 23, 0.95));
|
||||
--ol-panel-border: #27272a;
|
||||
--ol-panel-shadow: 0 28px 78px -42px rgba(0,0,0,0.70), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
--ol-card-bg: linear-gradient(180deg, rgba(29, 37, 48, 0.96), rgba(21, 28, 37, 0.94));
|
||||
--ol-card-border: rgba(226,232,240,0.09);
|
||||
--ol-card-bg: #18181b;
|
||||
--ol-card-border: #27272a;
|
||||
--ol-card-shadow: 0 18px 42px -30px rgba(0,0,0,0.62), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
|
||||
--ol-sidebar-bg: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.015));
|
||||
--ol-sidebar-border: rgba(226,232,240,0.08);
|
||||
--ol-sidebar-bg: #141417;
|
||||
--ol-sidebar-border: #27272a;
|
||||
--ol-sidebar-brand-bg: transparent;
|
||||
--ol-sidebar-brand-border: transparent;
|
||||
--ol-sidebar-pill-bg: linear-gradient(180deg, rgba(42, 54, 70, 0.86), rgba(27, 36, 48, 0.86));
|
||||
--ol-sidebar-pill-bg: linear-gradient(180deg, rgba(63, 63, 70, 0.86), rgba(39, 39, 42, 0.86));
|
||||
--ol-sidebar-pill-border: rgba(226,232,240,0.10);
|
||||
--ol-sidebar-footer-border: rgba(226,232,240,0.08);
|
||||
--ol-sidebar-version-bg: rgba(255,255,255,0.04);
|
||||
--ol-sidebar-version-border: rgba(226,232,240,0.08);
|
||||
--ol-sidebar-settings-bg: transparent;
|
||||
--ol-sidebar-settings-border: transparent;
|
||||
--ol-sidebar-settings-active-bg: linear-gradient(180deg, rgba(42, 54, 70, 0.86), rgba(27, 36, 48, 0.86));
|
||||
--ol-sidebar-settings-active-bg: linear-gradient(180deg, rgba(63, 63, 70, 0.86), rgba(39, 39, 42, 0.86));
|
||||
--ol-nav-hover-bg: rgba(116,183,255,0.10);
|
||||
|
||||
--ol-settings-rail-bg: linear-gradient(180deg, rgba(21, 28, 38, 0.98), rgba(15, 20, 28, 0.98));
|
||||
--ol-settings-rail-bg: linear-gradient(180deg, rgba(20, 20, 23, 0.98), rgba(13, 13, 15, 0.98));
|
||||
--ol-settings-rail-border: rgba(226,232,240,0.09);
|
||||
--ol-settings-links-border: rgba(226,232,240,0.08);
|
||||
--ol-settings-content-bg: linear-gradient(180deg, rgba(23, 30, 40, 0.98), rgba(17, 23, 31, 0.98));
|
||||
--ol-settings-content-bg: linear-gradient(180deg, rgba(24, 24, 27, 0.98), rgba(20, 20, 23, 0.98));
|
||||
--ol-settings-close-bg: rgba(255,255,255,0.05);
|
||||
--ol-settings-close-hover-bg: rgba(116,183,255,0.14);
|
||||
|
||||
--ol-control-muted: rgba(226,232,240,0.06);
|
||||
--ol-control-muted-strong: rgba(226,232,240,0.10);
|
||||
--ol-control-border: rgba(226,232,240,0.11);
|
||||
--ol-control-solid: rgba(24, 31, 41, 0.98);
|
||||
--ol-control-elevated: rgba(30, 38, 49, 0.98);
|
||||
--ol-control-solid: rgba(24, 24, 27, 0.98);
|
||||
--ol-control-elevated: rgba(39, 39, 42, 0.98);
|
||||
--ol-control-shadow: 0 12px 26px -22px rgba(0,0,0,0.64);
|
||||
--ol-pill-bg: rgba(226,232,240,0.07);
|
||||
--ol-pill-border: rgba(226,232,240,0.10);
|
||||
@@ -318,36 +312,36 @@
|
||||
--ol-pill-blue-border: rgba(116,183,255,0.30);
|
||||
--ol-pill-ok-bg: rgba(74,222,128,0.16);
|
||||
--ol-pill-ok-border: rgba(74,222,128,0.24);
|
||||
--ol-pill-dark-bg: linear-gradient(180deg, rgba(42, 54, 70, 0.92), rgba(25, 33, 44, 0.92));
|
||||
--ol-pill-dark-bg: linear-gradient(180deg, rgba(63, 63, 70, 0.92), rgba(39, 39, 42, 0.92));
|
||||
--ol-pill-dark-border: rgba(226,232,240,0.12);
|
||||
--ol-pill-dark-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
--ol-pill-selected-bg: linear-gradient(180deg, rgba(59, 130, 246, 0.95), rgba(37, 99, 235, 0.92));
|
||||
--ol-pill-selected-ink: #f4f7fb;
|
||||
--ol-pill-selected-border: rgba(147, 197, 253, 0.44);
|
||||
--ol-segmented-bg: rgba(226,232,240,0.07);
|
||||
--ol-segmented-active-bg: linear-gradient(180deg, rgba(44, 56, 72, 0.98), rgba(28, 37, 49, 0.98));
|
||||
--ol-segmented-active-bg: linear-gradient(180deg, rgba(63, 63, 70, 0.98), rgba(39, 39, 42, 0.98));
|
||||
--ol-segmented-active-shadow: 0 1px 2px rgba(0, 0, 0, 0.36), 0 0 0 0.5px rgba(226, 232, 240, 0.10), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
--ol-select-trigger-bg: var(--ol-control-solid);
|
||||
--ol-select-popover-bg: rgba(25, 32, 42, 0.98);
|
||||
--ol-select-popover-bg: rgba(24, 24, 27, 0.98);
|
||||
--ol-select-popover-border: rgba(226, 232, 240, 0.12);
|
||||
--ol-select-popover-shadow: 0 18px 42px -16px rgba(0, 0, 0, 0.62), 0 0 0 0.5px rgba(226, 232, 240, 0.08);
|
||||
--ol-select-option-hover-bg: rgba(116, 183, 255, 0.15);
|
||||
--ol-toggle-off-bg: rgba(226,232,240,0.18);
|
||||
--ol-toggle-knob: #f8fbff;
|
||||
--ol-toggle-knob: #fafafa;
|
||||
--ol-overlay-bg: rgba(5, 8, 13, 0.64);
|
||||
|
||||
--ol-style-shell-bg: linear-gradient(180deg, rgba(23, 30, 40, 0.96), rgba(17, 23, 31, 0.94));
|
||||
--ol-style-shell-bg: linear-gradient(180deg, rgba(24, 24, 27, 0.96), rgba(20, 20, 23, 0.94));
|
||||
--ol-style-shell-border: rgba(226,232,240,0.09);
|
||||
--ol-style-shell-shadow: 0 26px 70px -42px rgba(0,0,0,0.66);
|
||||
--ol-style-card-bg: linear-gradient(180deg, rgba(29, 37, 48, 0.96), rgba(21, 28, 37, 0.94));
|
||||
--ol-style-card-bg-active: linear-gradient(180deg, rgba(34, 45, 59, 0.98), rgba(24, 34, 48, 0.96));
|
||||
--ol-style-card-bg: linear-gradient(180deg, rgba(39, 39, 42, 0.96), rgba(24, 24, 27, 0.94));
|
||||
--ol-style-card-bg-active: linear-gradient(180deg, rgba(51, 51, 56, 0.98), rgba(39, 39, 42, 0.96));
|
||||
--ol-style-card-border: rgba(226,232,240,0.09);
|
||||
--ol-style-card-border-active: rgba(116,183,255,0.72);
|
||||
--ol-style-card-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
--ol-style-card-shadow-active: 0 0 0 1px rgba(116,183,255,0.70), 0 18px 42px -28px rgba(37,99,235,0.48);
|
||||
--ol-style-card-ink: #f4f7fb;
|
||||
--ol-style-card-ink-2: rgba(244,247,251,0.86);
|
||||
--ol-style-card-ink-3: rgba(244,247,251,0.64);
|
||||
--ol-style-card-ink: #fafafa;
|
||||
--ol-style-card-ink-2: rgba(250,250,250,0.86);
|
||||
--ol-style-card-ink-3: rgba(250,250,250,0.64);
|
||||
--ol-style-card-icon-bg: rgba(226,232,240,0.07);
|
||||
--ol-style-card-icon-bg-active: rgba(116,183,255,0.17);
|
||||
--ol-style-delete-bg: rgba(226,232,240,0.06);
|
||||
@@ -355,13 +349,13 @@
|
||||
--ol-style-add-tile-bg: rgba(226,232,240,0.035);
|
||||
--ol-style-add-tile-icon-bg: rgba(226,232,240,0.10);
|
||||
--ol-style-add-tile-border: rgba(226,232,240,0.12);
|
||||
--ol-style-input-bg: rgba(11, 15, 21, 0.38);
|
||||
--ol-style-input-bg: rgba(9, 9, 11, 0.38);
|
||||
--ol-style-input-border: rgba(226,232,240,0.10);
|
||||
--ol-style-input-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
--ol-style-subtle-bg: rgba(226,232,240,0.05);
|
||||
--ol-style-subtle-border: rgba(226,232,240,0.08);
|
||||
--ol-style-subtle-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
--ol-style-editor-bg: linear-gradient(180deg, rgba(24, 31, 41, 0.98), rgba(16, 22, 31, 0.96));
|
||||
--ol-style-editor-bg: linear-gradient(180deg, rgba(24, 24, 27, 0.98), rgba(20, 20, 23, 0.96));
|
||||
--ol-style-editor-border: rgba(226,232,240,0.10);
|
||||
--ol-style-editor-shadow: 0 30px 90px -42px rgba(0,0,0,0.70);
|
||||
|
||||
|
||||
Reference in new issue
Block a user