From 351e0211bd57abaf08e12c012d9136d911805bbb Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Sat, 4 Jul 2026 23:41:41 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E9=9B=86=E6=88=90=201.3.14=20=E2=86=92?= =?UTF-8?q?=20beta=EF=BC=88=E4=B8=BB=E7=AA=97=20UI=20=E5=A4=A7=E6=94=B9=20?= =?UTF-8?q?+=20=E4=B8=80=E6=89=B9=20fix/feat=EF=BC=89=20(#761)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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: 吕柏青 Co-authored-by: sim Co-authored-by: Claude Fable 5 --- openless-all/app/package-lock.json | 590 ++++++----- openless-all/app/package.json | 4 +- openless-all/app/src-tauri/Cargo.lock | 5 +- openless-all/app/src-tauri/Cargo.toml | 4 +- .../src/asr/local/apple_speech_provider.rs | 156 ++- .../app/src-tauri/src/asr/local/mod.rs | 2 +- .../app/src-tauri/src/asr/volcengine.rs | 111 +- .../app/src-tauri/src/coding_agent/stream.rs | 23 + .../app/src-tauri/src/commands/history.rs | 12 + .../app/src-tauri/src/commands/mod.rs | 3 +- openless-all/app/src-tauri/src/commands/qa.rs | 6 + openless-all/app/src-tauri/src/coordinator.rs | 47 +- .../src-tauri/src/coordinator/asr_wiring.rs | 14 +- .../src/coordinator/capsule_focus.rs | 36 +- .../src-tauri/src/coordinator/dictation.rs | 94 +- .../src-tauri/src/coordinator/qa_session.rs | 4 + openless-all/app/src-tauri/src/lib.rs | 114 +- .../app/src-tauri/src/persistence/activity.rs | 69 ++ .../src-tauri/src/persistence/credentials.rs | 59 +- .../app/src-tauri/src/persistence/mod.rs | 2 + openless-all/app/src-tauri/src/types.rs | 23 + openless-all/app/src-tauri/tauri.conf.json | 6 +- openless-all/app/src/App.tsx | 2 +- .../app/src/components/AutoUpdate.tsx | 2 +- openless-all/app/src/components/Capsule.tsx | 549 ++++------ .../app/src/components/FloatingShell.tsx | 329 +++--- openless-all/app/src/components/Heatmap.tsx | 218 ++++ openless-all/app/src/components/Kbd.tsx | 45 + openless-all/app/src/components/Marker.tsx | 103 ++ .../app/src/components/MarketplaceModal.tsx | 21 +- .../app/src/components/MessageScroller.tsx | 222 ++++ .../app/src/components/SettingsModal.tsx | 17 +- .../app/src/components/ShortcutRecorder.tsx | 8 +- openless-all/app/src/components/SiriGL.tsx | 452 ++++++++ .../app/src/components/ThinkingDots.tsx | 75 ++ openless-all/app/src/components/Tooltip.tsx | 135 +++ openless-all/app/src/components/ui/Modal.tsx | 11 +- openless-all/app/src/i18n/en.ts | 33 +- openless-all/app/src/i18n/ja.ts | 33 +- openless-all/app/src/i18n/ko.ts | 33 +- openless-all/app/src/i18n/zh-CN.ts | 37 +- openless-all/app/src/i18n/zh-TW.ts | 37 +- openless-all/app/src/lib/audioCue.test.ts | 36 + openless-all/app/src/lib/audioCue.ts | 107 +- .../app/src/lib/capsuleLayout.test.ts | 48 +- openless-all/app/src/lib/capsuleLayout.ts | 40 +- openless-all/app/src/lib/hotkey.ts | 11 +- openless-all/app/src/lib/ipc/history.ts | 9 +- openless-all/app/src/lib/ipc/index.ts | 6 +- openless-all/app/src/lib/ipc/less-computer.ts | 9 + openless-all/app/src/lib/ipc/mock-data.ts | 20 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 16 + .../app/src/pages/LessComputerGlow.tsx | 265 +++-- .../app/src/pages/LessComputerPanel.tsx | 691 ++++++++---- openless-all/app/src/pages/Marketplace.tsx | 33 +- openless-all/app/src/pages/Overview.tsx | 68 +- openless-all/app/src/pages/QaPanel.tsx | 986 ++++++++---------- openless-all/app/src/pages/Style.tsx | 33 +- .../src/pages/settings/LocalModelSection.tsx | 2 +- .../src/pages/settings/ProvidersSection.tsx | 4 +- .../src/pages/settings/RemoteInputSection.tsx | 2 +- .../app/src/pages/settings/ThemeSection.tsx | 13 +- openless-all/app/src/pages/settings/tabs.tsx | 48 +- openless-all/app/src/state/useAppState.ts | 1 + openless-all/app/src/styles/global.css | 79 ++ openless-all/app/src/styles/tokens.css | 232 ++--- 67 files changed, 4525 insertions(+), 1981 deletions(-) create mode 100644 openless-all/app/src-tauri/src/persistence/activity.rs create mode 100644 openless-all/app/src/components/Heatmap.tsx create mode 100644 openless-all/app/src/components/Kbd.tsx create mode 100644 openless-all/app/src/components/Marker.tsx create mode 100644 openless-all/app/src/components/MessageScroller.tsx create mode 100644 openless-all/app/src/components/SiriGL.tsx create mode 100644 openless-all/app/src/components/ThinkingDots.tsx create mode 100644 openless-all/app/src/components/Tooltip.tsx diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 2a43a412..a15ba2de 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.3.13-Beta.1", + "version": "1.3.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.3.13-Beta.1", + "version": "1.3.14", "dependencies": { "@formkit/auto-animate": "^0.9.0", "@tauri-apps/api": "^2.1.1", @@ -29,17 +29,17 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "typescript": "^5.6.3", - "vite": "^5.4.10" + "vite": "^6.4.3" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -48,9 +48,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -58,21 +58,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -89,14 +89,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -106,14 +106,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -123,9 +123,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -133,29 +133,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -175,9 +175,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -185,9 +185,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -195,9 +195,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -205,27 +205,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -276,33 +276,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -310,23 +310,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -337,13 +337,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -354,13 +354,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -371,13 +371,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -388,13 +388,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -405,13 +405,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -422,13 +422,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -439,13 +439,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -456,13 +456,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -473,13 +473,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -490,13 +490,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -507,13 +507,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -524,13 +524,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -541,13 +541,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -558,13 +558,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -575,13 +575,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -592,13 +592,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -609,13 +609,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -626,13 +643,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -643,13 +677,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -660,13 +711,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -677,13 +728,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -694,13 +745,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -711,7 +762,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@formkit/auto-animate": { @@ -1507,9 +1558,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.24", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", - "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1520,9 +1571,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -1540,10 +1591,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1554,9 +1605,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -1607,25 +1658,25 @@ } }, "node_modules/dompurify": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", - "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "node_modules/electron-to-chromium": { - "version": "1.5.344", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", - "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", "dev": true, "license": "ISC" }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1633,32 +1684,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escalade": { @@ -1671,6 +1725,24 @@ "node": ">=6" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/framer-motion": { "version": "12.39.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.39.0.tgz", @@ -1868,11 +1940,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/picocolors": { "version": "1.1.1", @@ -1881,6 +1956,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", @@ -2046,6 +2134,23 @@ "node": ">=0.10.0" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2107,21 +2212,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -2130,19 +2238,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -2163,6 +2277,12 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 45d7fdd1..ab314f71 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -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" } } diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index bae2f703..e621b2ec 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -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", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index ff16aed3..aa103296 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -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()` 释放。 diff --git a/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs b/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs index 91628f68..1c558d67 100644 --- a/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs @@ -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>, + /// 识别 locale(Apple 标识符,如 "zh-CN")。None = 用系统默认。由用户工作语言映射 + /// 而来 —— SFSpeechRecognizer 一个实例只认一种语言,不显式指定就落到系统首选语言 + /// (常是英文),中文语音会被英文引擎识别成英文且理解错误(用户报告的根因)。 + locale: Option, } impl AppleSpeechAsr { - pub fn new() -> Self { + pub fn new(locale: Option) -> 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 { +fn transcribe_pcm_blocking( + pcm: &[u8], + duration_ms: u64, + locale: Option<&str>, +) -> Result { ensure_authorized()?; let samples: Vec = pcm @@ -129,7 +143,7 @@ fn transcribe_pcm_blocking(pcm: &[u8], duration_ms: u64) -> Result Result<()> { /// 用 `SFSpeechURLRecognitionRequest` 对给定 wav 文件做一次批处理识别, /// 把 `recognitionTaskWithRequest:resultHandler:` 的异步回调同步化。 -fn recognize_file(wav_path: &str) -> Result { - let recognizer = create_recognizer()?; +fn recognize_file(wav_path: &str, locale: Option<&str>) -> Result { + 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::(); // 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 { } } +/// 轮询等待识别引擎就绪。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:]`。构造失败返回 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 { + 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:]`。 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); diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 627f3ecf..cb60aec9 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -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")] diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index f356710c..cc595600 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -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 + { + 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 { + 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 全部丢弃 = 尾句吞掉。 diff --git a/openless-all/app/src-tauri/src/coding_agent/stream.rs b/openless-all/app/src-tauri/src/coding_agent/stream.rs index d1349eb0..78b8d722 100644 --- a/openless-all/app/src-tauri/src/coding_agent/stream.rs +++ b/openless-all/app/src-tauri/src/coding_agent/stream.rs @@ -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 { + // 上下文压缩: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!( diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index e8fa29ff..1f6a84ab 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -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 { + coord + .activity() + .snapshot() + .into_iter() + .map(|(date, count)| ActivityDay { date, count }) + .collect() +} + /// 读取某次会话的原始麦克风 wav 字节流。文件存在的条件:debug 用户的任意会话,或任意 /// 「转录失败 / empty」会话(失败保留)——成功的非 debug 会话录音会在插入后删掉。 /// 文件名规约:`/recordings/.wav`,与 DictationSession.id 同名。 diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index cfb150be..bac691e0 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -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, diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index e4558b64..a125b8a0 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -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),故仅允许该窗口提交, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9acfd322..ec296073 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -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>, 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>, + /// 预备态标志:按下热键即"乐观显示"胶囊(带入场动画),此时麦克风还在 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>, /// 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 } diff --git a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs index 5d9353ba..8f949f40 100644 --- a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs +++ b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs @@ -299,8 +299,16 @@ pub(super) async fn build_local_qwen3( } #[cfg(target_os = "macos")] -pub(super) fn build_apple_speech() -> Arc { - Arc::new(crate::asr::local::AppleSpeechAsr::new()) +pub(super) fn build_apple_speech( + prefs: &crate::types::UserPreferences, +) -> Arc { + // 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, 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 = local; return Ok(QaAsrStart::Ready { active, consumer }); diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index 5b7c7ddc..2398433c 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -387,11 +387,16 @@ pub(super) fn emit_capsule( inserted_chars: Option, ) { // 在 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); } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 922d6866..bbf82204 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -95,9 +95,10 @@ fn emit_less_computer(inner: &Arc, 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, @@ -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) { } /// 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, _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) -> 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 转换生效。 + 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, - ChineseScriptPreference::Traditional, ] { - 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] diff --git a/openless-all/app/src-tauri/src/coordinator/qa_session.rs b/openless-all/app/src-tauri/src/coordinator/qa_session.rs index 8e9664c1..094a1c6c 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa_session.rs @@ -690,6 +690,10 @@ pub(super) async fn begin_qa_session(inner: &Arc) -> 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); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 369ee0c6..4035c063 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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(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(app: &AppHandle) -> Option { + // ⚠️ 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] diff --git a/openless-all/app/src-tauri/src/persistence/activity.rs b/openless-all/app/src-tauri/src/persistence/activity.rs new file mode 100644 index 00000000..35c9aa85 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/activity.rs @@ -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>, +} + +impl ActivityStore { + pub fn load() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + let path = dir.join(ACTIVITY_FILE); + let cache: BTreeMap = 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() + } +} diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 6e160ec7..22dd7e27 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -451,13 +451,62 @@ fn read_chunk_manifest(json: &str) -> Option { } } +/// 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> { - 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}")) + } } } } diff --git a/openless-all/app/src-tauri/src/persistence/mod.rs b/openless-all/app/src-tauri/src/persistence/mod.rs index 0e99f47a..51e0da59 100644 --- a/openless-all/app/src-tauri/src/persistence/mod.rs +++ b/openless-all/app/src-tauri/src/persistence/mod.rs @@ -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::*; diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 0de6b479..69d6a9c8 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -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, @@ -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 diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 7c12331e..6f2de3b8 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -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, diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 28bdb085..2801e198 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -53,7 +53,7 @@ const ANDROID_SETUP_WIZARD_COMPLETE_KEY = 'openless.androidSetupWizardComplete'; export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) { if (isCapsule) { - return ; + return ; } if (isQa) { return ( diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 1c8842c9..0281a645 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -303,7 +303,7 @@ export function UpdateDialog({ const androidInstalled = isAndroid() && status === 'downloaded'; return (
-
+
{t(`settings.about.updateDialog.${status}.title`)}
{androidInstalled diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index bfbc7135..3143b9ac 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -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 这个