diff --git a/.gitignore b/.gitignore index ea8df9b..ce8bdc7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist-test/ release/ packaging/out/ chatgpt_amd64.deb +backend.js +settings.html diff --git a/AGENTS.md b/AGENTS.md index 22ad4cf..156f64a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## 项目范围 -MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.0`。 +MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.7`。 不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。 diff --git a/CLAUDE.md b/CLAUDE.md index 22ad4cf..156f64a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## 项目范围 -MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.0`。 +MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.7`。 不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。 diff --git a/README.md b/README.md index 3c75081..792a74d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,32 @@ # MeloASR -MeloASR 是面向 Linux/Fcitx5 的桌面语音输入工具。应用使用受支持网页平台自身的录音、流式识别、纠错、断句和标点能力,并把网页编辑器中的当前完整文本实时同步到活动的 Fcitx5 InputContext。 +大模型的进步推动了自然语言处理的进化,语音输入正在成为主流。在其它桌面平台下,微信输入法,豆包输入法,千问输入法等支持语音输入甚至以此为卖点的新兴中文输入法正在成为主流。 +而在linux下,相关领域却长期空白,现有实现主要聚焦于以下几种类型: + + +api调用——调用各大ai厂商的语音转文字api。显然,该方案最大的问题在于——要钱。 +本地模型——识别准确率低,技术落后,和其他平台的能实时校正的ai语音输入根本不是一个量级的东西,尤其是针对中文环境。 + +我们注意到,很多大模型的网页对话窗口,都支持语音输入,而且完全免费——因为语音输入最后只是输入到文本框,并没有发送实际的对话请求,而大模型就算是付费也往往是根据对话请求来计费的。 + +因此,本项目使用了一种全新的思路:在后台运行一个electron应用,并在里面登录千问,元宝等网页版对话作为后端,实时抓取语音输入到输入框中的结果,并传递给fcitx5,从而让linux用户也用上高质量的语音输入。 + +简而言之,MeloASR 是面向 Linux/Fcitx5 的桌面语音输入工具。应用使用受支持网页平台自身的录音、流式识别、纠错、断句和标点能力,并把网页编辑器中的当前完整文本实时同步到活动的 Fcitx5 InputContext。 当前后端: - 千问 - 元宝 +### 本项目暂不考虑迁移到Tauri。我们充分理解这种需要后台常驻的系统性应用,在Tauri下开发似乎更加合适,事实上我们也确实进行了两次Tauri迁移。但元宝和千问web版对于webkit2gtk的表现实在是过于糟糕了(至少是在我的目标设备上),即使使用默认的gnome浏览器进行语音输入都会卡死,更不用说后续自动化的抓取了。还请各位理解,该项目是一个偏个人向的项目(毕竟使用的这种方式,大规模公开那就是商业侵权),而我的目标开发设备是一台非常老旧的设备,我没法说因为为了在他人的设备上获得更出色的性能而放弃对自己设备的适配。也许未来我们会在这些web端的兼容性得到改善,或者该目标设备被淘汰以后,才会考虑向Tauri迁移。 + +### 当前electron版的常驻占用内存在200mb左右。 + +### 项目暂不支持豆包后端。豆包的ASR输入是直接发送请求,而非像元宝和千问一样输出到输入框,这就需要拦截或自行重新实现请求,技术难度高且有风控风险。 + +### 同时该项目是中文输入主导的,也不考虑适配chatgpt等非中文优先的平台。 + + ## 架构 ```text @@ -75,5 +95,3 @@ npm run build npm run build:fcitx5 ctest --test-dir linux/fcitx5/build --output-on-failure ``` - -元宝仍需要使用具备语音输入权限的真实账号完成发布前端到端验证。不要把页面能加载等同于语音识别已经验证。 diff --git a/linux/fcitx5/keyeventclassifier.h b/linux/fcitx5/keyeventclassifier.h new file mode 100644 index 0000000..9c47f2b --- /dev/null +++ b/linux/fcitx5/keyeventclassifier.h @@ -0,0 +1,28 @@ +#pragma once + +enum class VoiceKeyAction { + Pass, + Start, + Stop, + ConsumeHeldTrigger, + Cancel, +}; + +inline VoiceKeyAction classifyVoiceKeyEvent(bool isRelease, bool held, + bool triggerMatches, + bool rawSymbolMatches, + bool repeat, bool modifier, + bool sessionPending) { + if (!isRelease && triggerMatches) { + return (repeat || held) ? VoiceKeyAction::ConsumeHeldTrigger + : VoiceKeyAction::Start; + } + if (held && rawSymbolMatches) { + return isRelease ? VoiceKeyAction::Stop + : VoiceKeyAction::ConsumeHeldTrigger; + } + if (!sessionPending || isRelease || modifier) { + return VoiceKeyAction::Pass; + } + return VoiceKeyAction::Cancel; +} diff --git a/linux/fcitx5/preeditfinalizer.h b/linux/fcitx5/preeditfinalizer.h new file mode 100644 index 0000000..b329667 --- /dev/null +++ b/linux/fcitx5/preeditfinalizer.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +template +void finalizePreedit(const std::string &text, ClearPreedit &&clearPreedit, + CommitText &&commitText) { + std::forward(clearPreedit)(); + if (!text.empty()) { + std::forward(commitText)(text); + } +} diff --git a/linux/fcitx5/protocolsession_test.cpp b/linux/fcitx5/protocolsession_test.cpp index 584278e..968c05d 100644 --- a/linux/fcitx5/protocolsession_test.cpp +++ b/linux/fcitx5/protocolsession_test.cpp @@ -1,6 +1,10 @@ #include "protocolsession.h" +#include "preeditfinalizer.h" +#include "keyeventclassifier.h" #include +#include +#include int main() { ProtocolSession session; @@ -18,4 +22,19 @@ int main() { session.start("round-2"); assert(!session.accept("round-1", 100)); assert(session.accept("round-2", 1)); + std::vector finalizationEvents; + finalizePreedit( + "最终文本", + [&finalizationEvents]() { finalizationEvents.emplace_back("clear"); }, + [&finalizationEvents](const std::string &text) { + finalizationEvents.emplace_back("commit:" + text); + }); + assert((finalizationEvents == std::vector{"clear", "commit:最终文本"})); + + assert(classifyVoiceKeyEvent(false, true, false, true, false, false, false) == + VoiceKeyAction::ConsumeHeldTrigger); + assert(classifyVoiceKeyEvent(true, true, false, true, false, false, false) == + VoiceKeyAction::Stop); + assert(classifyVoiceKeyEvent(false, true, false, false, false, false, true) == + VoiceKeyAction::Cancel); } diff --git a/linux/fcitx5/voiceinput.cpp b/linux/fcitx5/voiceinput.cpp index 4d98c66..b696f75 100644 --- a/linux/fcitx5/voiceinput.cpp +++ b/linux/fcitx5/voiceinput.cpp @@ -28,12 +28,16 @@ #include #include "protocolsession.h" +#include "preeditfinalizer.h" +#include "keyeventclassifier.h" namespace { constexpr int kProtocolVersion = 2; constexpr std::size_t kMaxBufferBytes = 1024 * 1024; +#define MELOASR_DIAGNOSTIC_INFO if (!diagnosticLogging_) {} else FCITX_INFO() + class VoiceInputAddon final : public fcitx::AddonInstance { public: explicit VoiceInputAddon(fcitx::Instance *instance) @@ -50,6 +54,8 @@ public: fcitx::EventType::InputContextFocusOut, fcitx::EventWatcherPhase::PostInputMethod, [this](fcitx::Event &event) { + MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext focusOut,取消当前会话 active=" + << session_.active() << " awaiting=" << awaitingStart_; cancelForContext(static_cast(event).inputContext()); }); focusInWatcher_ = instance_->watchEvent( @@ -63,6 +69,8 @@ public: fcitx::EventType::InputContextReset, fcitx::EventWatcherPhase::PostInputMethod, [this](fcitx::Event &event) { + MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext reset,取消当前会话 active=" + << session_.active() << " awaiting=" << awaitingStart_; cancelForContext(static_cast(event).inputContext()); }); destroyedWatcher_ = instance_->watchEvent( @@ -72,8 +80,9 @@ public: auto *context = static_cast(event).inputContext(); if (context == focusedInputContext_) focusedInputContext_ = nullptr; if (context == inputContext_) { + MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext destroyed,取消当前会话"; sendControl("request-cancel"); - resetLocalState(false, false); + resetLocalState(false); } }); reconnectTimer_ = instance_->eventLoop().addTimeEvent( @@ -91,24 +100,28 @@ public: ~VoiceInputAddon() override { if (inputContext_) { clearPreedit(); - restorePreeditState(); } disconnectSocket(); } private: void handleKeyEvent(fcitx::KeyEvent &event) { - const bool isTriggerPress = !event.isRelease() && event.key().check(trigger_); - const bool isTriggerRelease = event.isRelease() && held_ && - event.rawKey().sym() == trigger_.sym(); + const VoiceKeyAction action = classifyVoiceKeyEvent( + event.isRelease(), held_, event.key().check(trigger_), + event.rawKey().sym() == trigger_.sym(), + event.rawKey().states().test(fcitx::KeyState::Repeat), + event.key().isModifier(), session_.active() || awaitingStart_); - if (isTriggerPress) { + if (action == VoiceKeyAction::ConsumeHeldTrigger) { event.filterAndAccept(); - if (event.rawKey().states().test(fcitx::KeyState::Repeat)) return; - FCITX_INFO() << "网页语音输入:快捷键按下 held=" << held_ + return; + } + + if (action == VoiceKeyAction::Start) { + event.filterAndAccept(); + MELOASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键按下 held=" << held_ << " awaiting=" << awaitingStart_ << " active=" << session_.active(); - if (held_) return; if (!ensureConnected()) { FCITX_WARN() << "网页语音输入:Electron Socket 尚未就绪"; return; @@ -118,20 +131,20 @@ private: return; } - if (isTriggerRelease) { + if (action == VoiceKeyAction::Stop) { event.filterAndAccept(); - FCITX_INFO() << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_ + MELOASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_ << " active=" << session_.active(); held_ = false; if (awaitingStart_ || session_.active()) sendControl("request-stop"); return; } - if (!session_.active() && !awaitingStart_) return; - if (event.isRelease() || event.key().isModifier()) return; + if (action == VoiceKeyAction::Pass) return; if (event.key().check(FcitxKey_Escape)) { event.filterAndAccept(); + MELOASR_DIAGNOSTIC_INFO << "MeloASR:录音中收到 Escape,取消当前会话"; sendControl("request-cancel"); clearPreedit(); resetLocalState(false); @@ -139,6 +152,9 @@ private: } // 用户开始键盘输入时,立即取消语音会话,并让该按键继续交给原输入法。 + MELOASR_DIAGNOSTIC_INFO << "MeloASR:录音中收到其它按键,取消当前会话 key=" + << event.key().toString() << " rawKey=" << event.rawKey().toString() + << " repeat=" << event.rawKey().states().test(fcitx::KeyState::Repeat); sendControl("request-cancel"); clearPreedit(); resetLocalState(false); @@ -152,9 +168,6 @@ private: return false; } inputContext_ = context; - preeditWasEnabled_ = inputContext_->isPreeditEnabled(); - preeditStateCaptured_ = true; - inputContext_->setEnablePreedit(true); awaitingStart_ = true; sendControl("request-start"); return true; @@ -167,7 +180,7 @@ private: if (probe > 0 || (probe < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))) { return true; } - FCITX_INFO() << "网页语音输入:检测到失效的 Electron Socket,立即重连"; + MELOASR_DIAGNOSTIC_INFO << "网页语音输入:检测到失效的 Electron Socket,立即重连"; handleDisconnect(); } const char *runtimeDir = std::getenv("XDG_RUNTIME_DIR"); @@ -248,6 +261,9 @@ private: return; } if (type == "configure") { + if (message.contains("diagnosticLogging") && message["diagnosticLogging"].is_boolean()) { + diagnosticLogging_ = message["diagnosticLogging"].get(); + } if (!message.contains("shortcut") || !message["shortcut"].is_string() || awaitingStart_ || session_.active()) return; fcitx::Key candidate(message["shortcut"].get()); @@ -256,7 +272,7 @@ private: return; } trigger_ = std::move(candidate); - FCITX_INFO() << "MeloASR:快捷键已更新为 " << trigger_.toString(); + MELOASR_DIAGNOSTIC_INFO << "MeloASR:快捷键已更新为 " << trigger_.toString(); return; } if (type == "activate") { @@ -270,14 +286,17 @@ private: if (!message.contains("sessionId") || !message["sessionId"].is_string()) return; session_.start(message["sessionId"].get()); awaitingStart_ = false; - FCITX_INFO() << "网页语音输入:会话开始,clientPreedit=" + MELOASR_DIAGNOSTIC_INFO << "网页语音输入:会话开始,clientPreedit=" << inputContext_->capabilityFlags().test(fcitx::CapabilityFlag::Preedit); - setPreedit(""); + // 新会话尚无文本;重复发布空 preedit 会使部分 frontend 立刻 reset。 return; } if (type == "cancel") { const std::string incoming = message.value("sessionId", std::string()); if (!incoming.empty() && !session_.matches(incoming)) return; + MELOASR_DIAGNOSTIC_INFO << "MeloASR:收到 Electron cancel,sessionIdEmpty=" + << incoming.empty() << " message=" + << message.value("message", std::string()); clearPreedit(); resetLocalState(false); return; @@ -288,14 +307,17 @@ private: const std::int64_t incomingRevision = message["revision"].get(); if (!session_.accept(message.value("sessionId", std::string()), incomingRevision)) return; currentText_ = message["text"].get(); - FCITX_INFO() << "网页语音输入:收到 " << type << " revision=" + MELOASR_DIAGNOSTIC_INFO << "网页语音输入:收到 " << type << " revision=" << incomingRevision << " bytes=" << currentText_.size(); setPreedit(currentText_); if (type == "finish") { - if (inputContext_ && inputContext_->hasFocus() && !currentText_.empty()) { - inputContext_->commitString(currentText_); - } - clearPreedit(); + finalizePreedit( + currentText_, [this]() { clearPreedit(); }, + [this](const std::string &text) { + if (inputContext_ && inputContext_->hasFocus()) { + inputContext_->commitString(text); + } + }); resetLocalState(false); } } @@ -325,15 +347,7 @@ private: resetLocalState(false); } - void restorePreeditState() { - if (inputContext_ && preeditStateCaptured_) { - inputContext_->setEnablePreedit(preeditWasEnabled_); - } - preeditStateCaptured_ = false; - } - - void resetLocalState(bool keepContext, bool restorePreedit = true) { - if (restorePreedit) restorePreeditState(); + void resetLocalState(bool keepContext) { held_ = false; awaitingStart_ = false; session_.reset(); @@ -361,6 +375,8 @@ private: } void handleDisconnect() { + MELOASR_DIAGNOSTIC_INFO << "MeloASR:Electron Socket 断开,取消当前会话 active=" + << session_.active() << " awaiting=" << awaitingStart_; clearPreedit(); resetLocalState(false); disconnectSocket(); @@ -379,8 +395,7 @@ private: fcitx::InputContext *focusedInputContext_ = nullptr; bool held_ = false; bool awaitingStart_ = false; - bool preeditStateCaptured_ = false; - bool preeditWasEnabled_ = true; + bool diagnosticLogging_ = false; ProtocolSession session_; std::string currentText_; int socketFd_ = -1; diff --git a/package-lock.json b/package-lock.json index b42b7e2..1837748 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meloasr", - "version": "0.1.0", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meloasr", - "version": "0.1.0", + "version": "0.1.7", "devDependencies": { "@types/node": "^24.0.0", "electron": "42.3.0", diff --git a/package.json b/package.json index 5540a41..934d66d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meloasr", "productName": "MeloASR", - "version": "0.1.0", + "version": "0.1.7", "private": true, "author": "MeloASR contributors", "description": "基于网页语音识别后端和 Fcitx5 的 Linux 实时语音输入工具", diff --git a/packaging/README.md b/packaging/README.md index 143dc58..8f5684f 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,6 +1,6 @@ # Linux 分发打包 -MeloASR 0.1.0 的系统包由两部分组成: +MeloASR 0.1.7 的系统包由两部分组成: - Electron 应用安装到 `/opt/meloasr`,命令入口为 `/usr/bin/meloasr`; - Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。 @@ -40,7 +40,7 @@ npm run build:linux:dir packaging/deb/build-deb.sh ``` -产物位于 `packaging/out/meloasr_0.1.0_.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。 +产物位于 `packaging/out/meloasr_0.1.7_.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。 ## rpm @@ -68,7 +68,7 @@ flake 使用 nixpkgs 的 Electron,Node 依赖由 `package-lock.json` 的 integ ## 发布前必须完成 -- 将所有 `example.invalid` 替换成正式仓库地址; +- 发布前确认仓库地址和许可证元数据; - 明确项目许可证,并替换临时的 `LicenseRef-Proprietary`/Nix license; - 生成 Arch 源码归档真实 SHA-256; - 在干净的 Debian、RPM、Arch 和 Nix 构建环境逐一构建、安装、卸载; diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index f1795cf..0d934c9 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,9 +1,9 @@ pkgname=meloasr -pkgver=0.1.0 +pkgver=0.1.7 pkgrel=1 pkgdesc='Web-backed voice input for Fcitx5' arch=('x86_64') -url='https://example.invalid/meloasr' +url='https://github.com/Melorise/MeloASR' license=('custom') depends=('fcitx5' 'gtk3' 'nss' 'libxss' 'mesa' 'alsa-lib') makedepends=('nodejs' 'npm' 'cmake' 'gcc' 'nlohmann-json') diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 42de696..c2d6ab1 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -1,10 +1,9 @@ # Arch Linux 构建 -发布时把项目源码归档为 `meloasr-0.1.0.tar.gz`,与 `PKGBUILD` 放在同一目录,随后运行: +发布时把项目源码归档为 `meloasr-0.1.7.tar.gz`,与 `PKGBUILD` 放在同一目录,随后运行: ```bash makepkg --syncdeps --cleanbuild ``` -正式发布必须把 `source` 改为仓库的不可变 release URL,并以 `updpkgsums` 写入真实 SHA-256;仓库地址确定前保留的 `example.invalid` 和 `SKIP` 不能用于发布。 - +正式发布必须把 `source` 改为仓库的不可变 release URL,并以 `updpkgsums` 写入真实 SHA-256;当前源码包仍保留 `SKIP`,不能直接作为正式 Arch 发布源。 diff --git a/packaging/assets/meloasr-wrapper b/packaging/assets/meloasr-wrapper index d23f255..fc2acd1 100755 --- a/packaging/assets/meloasr-wrapper +++ b/packaging/assets/meloasr-wrapper @@ -1,3 +1,2 @@ #!/bin/sh -exec /opt/meloasr/meloasr "$@" - +exec /opt/meloasr/meloasr --ozone-platform=x11 "$@" diff --git a/packaging/assets/meloasr.metainfo.xml b/packaging/assets/meloasr.metainfo.xml index cdb4c1f..9292422 100644 --- a/packaging/assets/meloasr.metainfo.xml +++ b/packaging/assets/meloasr.metainfo.xml @@ -10,12 +10,12 @@

MeloASR connects supported web speech-recognition backends to the active Fcitx5 input context.

MeloASR 将支持的网页语音识别后端连接到当前 Fcitx5 输入上下文。

- https://example.invalid/meloasr + https://github.com/Melorise/MeloASR meloasr.desktop meloasr - + diff --git a/packaging/deb/build-deb.sh b/packaging/deb/build-deb.sh index 1d67ffd..793dffe 100755 --- a/packaging/deb/build-deb.sh +++ b/packaging/deb/build-deb.sh @@ -2,7 +2,7 @@ set -euo pipefail project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -version="${MELOASR_VERSION:-0.1.0}" +version="${MELOASR_VERSION:-0.1.7}" arch="${MELOASR_DEB_ARCH:-$(dpkg --print-architecture)}" app_dir="${1:-${project_dir}/release/linux-unpacked}" work_dir="${project_dir}/packaging/out/deb-root" diff --git a/packaging/nix/package.nix b/packaging/nix/package.nix index 406724b..e4f3a50 100644 --- a/packaging/nix/package.nix +++ b/packaging/nix/package.nix @@ -12,7 +12,7 @@ stdenv.mkDerivation (finalAttrs: { pname = "meloasr"; - version = "0.1.0"; + version = "0.1.7"; src = ../..; @@ -82,7 +82,7 @@ stdenv.mkDerivation (finalAttrs: { meta = { description = "Web-backed voice input for Fcitx5"; - homepage = "https://example.invalid/meloasr"; + homepage = "https://github.com/Melorise/MeloASR"; license = lib.licenses.unfreeRedistributable; mainProgram = "meloasr"; platforms = lib.platforms.linux; diff --git a/packaging/rpm/build-rpm.sh b/packaging/rpm/build-rpm.sh index d7e4fe5..f4500ee 100755 --- a/packaging/rpm/build-rpm.sh +++ b/packaging/rpm/build-rpm.sh @@ -2,7 +2,7 @@ set -euo pipefail project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -version="${MELOASR_VERSION:-0.1.0}" +version="${MELOASR_VERSION:-0.1.7}" app_dir="${1:-${project_dir}/release/linux-unpacked}" stage_dir="${project_dir}/packaging/out/rpm-root" top_dir="${project_dir}/packaging/out/rpmbuild" @@ -23,4 +23,3 @@ rpmbuild -bb \ "${project_dir}/packaging/rpm/meloasr-binary.spec" find "${top_dir}/RPMS" -type f -name '*.rpm' -exec cp -f {} "${project_dir}/packaging/out/" \; - diff --git a/packaging/rpm/meloasr-binary.spec b/packaging/rpm/meloasr-binary.spec index afbfaf1..6f79f55 100644 --- a/packaging/rpm/meloasr-binary.spec +++ b/packaging/rpm/meloasr-binary.spec @@ -3,7 +3,7 @@ Version: %{meloasr_version} Release: 1%{?dist} Summary: Web-backed voice input for Fcitx5 License: LicenseRef-Proprietary -URL: https://example.invalid/meloasr +URL: https://github.com/Melorise/MeloASR Source0: meloasr-root.tar.gz Requires: fcitx5 @@ -36,5 +36,5 @@ tar -C %{buildroot} -xzf %{SOURCE0} /etc/xdg/autostart/meloasr.desktop %changelog -* Sun Aug 23 2026 MeloASR contributors - 0.1.0-1 +* Sun Aug 23 2026 MeloASR contributors - 0.1.7-1 - Initial package diff --git a/scripts/copy-static.mjs b/scripts/copy-static.mjs index 15f2153..86cf325 100644 --- a/scripts/copy-static.mjs +++ b/scripts/copy-static.mjs @@ -7,7 +7,11 @@ const copies = [ ['src/renderer/settings.css', 'dist/renderer/settings.css'], ['src/renderer/overlay.html', 'dist/renderer/overlay.html'], ['src/renderer/overlay.css', 'dist/renderer/overlay.css'], - ['logo.png', 'dist/assets/logo.png'] + ['logo.png', 'dist/assets/logo.png'], + ['src/assets/tray-loading.png', 'dist/assets/tray-loading.png'], + ['src/assets/tray-ready.png', 'dist/assets/tray-ready.png'], + ['src/assets/tray-login-required.png', 'dist/assets/tray-login-required.png'], + ['src/assets/tray-error.png', 'dist/assets/tray-error.png'] ]; for (const [source, target] of copies) { diff --git a/src/assets/tray-error.png b/src/assets/tray-error.png new file mode 100644 index 0000000..064e6b5 Binary files /dev/null and b/src/assets/tray-error.png differ diff --git a/src/assets/tray-loading.png b/src/assets/tray-loading.png new file mode 100644 index 0000000..36ddd23 Binary files /dev/null and b/src/assets/tray-loading.png differ diff --git a/src/assets/tray-login-required.png b/src/assets/tray-login-required.png new file mode 100644 index 0000000..4937c30 Binary files /dev/null and b/src/assets/tray-login-required.png differ diff --git a/src/assets/tray-ready.png b/src/assets/tray-ready.png new file mode 100644 index 0000000..d3a9093 Binary files /dev/null and b/src/assets/tray-ready.png differ diff --git a/src/assets/tray-template.svg b/src/assets/tray-template.svg new file mode 100644 index 0000000..cb8ff59 --- /dev/null +++ b/src/assets/tray-template.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/src/backends/contracts.ts b/src/backends/contracts.ts index 9e78172..b5ad214 100644 --- a/src/backends/contracts.ts +++ b/src/backends/contracts.ts @@ -22,9 +22,15 @@ export interface BackendPageStatus { /** 仅在后端网页 preload 的渲染进程中调用。 */ export interface BackendWebAdapter { readonly definition: BackendDefinition; - readonly stopDelayMs: number; + /** 网页停止录音后的最短缓冲、文本静默窗口和异常兜底。 */ + readonly stopCompletion: Readonly<{ + minimumWaitMs: number; + quietWindowMs: number; + timeoutMs: number; + }>; findEditor(document: Document): HTMLElement | null; findMicrophone(document: Document): HTMLElement | null; + isRecording(document: Document): boolean; serialize(editor: HTMLElement): string; detectPageStatus(document: Document): BackendPageStatus; } diff --git a/src/backends/dom.ts b/src/backends/dom.ts index 18cd268..c1e029f 100644 --- a/src/backends/dom.ts +++ b/src/backends/dom.ts @@ -1,4 +1,5 @@ -import type { BackendLoginStatus, BackendPageStatus } from './contracts'; +import type { BackendPageStatus } from './contracts'; +import { decideOperationalStatus } from './status-decision'; export function isVisible(element: Element | null): element is HTMLElement { if (!(element instanceof HTMLElement)) return false; @@ -13,7 +14,7 @@ export function isUsableControl(element: Element | null): element is HTMLElement export function firstVisible(document: Document, selector: string): HTMLElement | null { const elements = Array.from(document.querySelectorAll(selector)); - return elements.find(isVisible) ?? (elements[0] instanceof HTMLElement ? elements[0] : null); + return elements.find(isVisible) ?? null; } export function controlDescription(element: Element): string { @@ -22,13 +23,14 @@ export function controlDescription(element: Element): string { element.getAttribute('title'), element.getAttribute('data-tooltip'), element.textContent, + element.getAttribute('value'), element.getAttribute('class'), element.querySelector('svg')?.getAttribute('class') ].filter(Boolean).join(' '); } export function findSemanticControl(document: Document, pattern: RegExp): HTMLElement | null { - const control = Array.from(document.querySelectorAll('button, a, [role="button"]')) + const control = Array.from(document.querySelectorAll('button, a, [role="button"], input[type="button"], input[type="submit"]')) .find((element) => pattern.test(controlDescription(element)) && isUsableControl(element)); return control instanceof HTMLElement ? control : null; } @@ -39,20 +41,6 @@ export function operationalStatus( microphone: HTMLElement | null, loginPattern: RegExp ): BackendPageStatus { - if (editor && microphone) return { loginStatus: 'logged-in', ready: true }; - if (editor) { - return { - loginStatus: 'logged-in', - ready: false, - message: '语音入口尚未就绪' - }; - } - const loginControl = findSemanticControl(document, loginPattern); - const loginStatus: BackendLoginStatus = loginControl ? 'logged-out' : 'unknown'; - return { - loginStatus, - ready: false, - message: loginControl ? '尚未登录' : '页面尚未进入可语音输入状态' - }; + return decideOperationalStatus(Boolean(editor), Boolean(microphone), Boolean(loginControl)); } diff --git a/src/backends/qianwen.ts b/src/backends/qianwen.ts index 4f85749..b0c4f66 100644 --- a/src/backends/qianwen.ts +++ b/src/backends/qianwen.ts @@ -1,10 +1,10 @@ import type { BackendWebAdapter } from './contracts'; import { qianwenDefinition } from './definitions'; -import { firstVisible, isUsableControl, operationalStatus } from './dom'; +import { controlDescription, firstVisible, isUsableControl, operationalStatus } from './dom'; export const qianwenAdapter: BackendWebAdapter = { definition: qianwenDefinition, - stopDelayMs: 1_600, + stopCompletion: { minimumWaitMs: 250, quietWindowMs: 350, timeoutMs: 2_500 }, findEditor(document) { return firstVisible(document, qianwenDefinition.editorSelector); @@ -18,6 +18,12 @@ export const qianwenAdapter: BackendWebAdapter = { return control instanceof HTMLElement && isUsableControl(control) ? control : null; }, + isRecording(document) { + const microphone = this.findMicrophone(document); + return microphone?.getAttribute('aria-pressed') === 'true' || + /停止语音|stop\s*(voice|recording|input)/i.test(microphone ? controlDescription(microphone) : ''); + }, + serialize(editor) { return Array.from(editor.children) .filter((node) => node.nodeType === Node.ELEMENT_NODE) diff --git a/src/backends/status-decision.ts b/src/backends/status-decision.ts new file mode 100644 index 0000000..e6f3ffa --- /dev/null +++ b/src/backends/status-decision.ts @@ -0,0 +1,26 @@ +import type { BackendLoginStatus, BackendPageStatus } from './contracts'; + +export function decideOperationalStatus( + hasEditor: boolean, + hasMicrophone: boolean, + hasLoginControl: boolean +): BackendPageStatus { + if (hasLoginControl) { + return { loginStatus: 'logged-out', ready: false, message: '尚未登录' }; + } + if (hasEditor && hasMicrophone) return { loginStatus: 'logged-in', ready: true }; + if (hasEditor) { + return { + loginStatus: 'logged-in', + ready: false, + message: '语音入口尚未就绪' + }; + } + + const loginStatus: BackendLoginStatus = 'unknown'; + return { + loginStatus, + ready: false, + message: '页面尚未进入可语音输入状态' + }; +} diff --git a/src/backends/yuanbao.ts b/src/backends/yuanbao.ts index f025b42..71cea7f 100644 --- a/src/backends/yuanbao.ts +++ b/src/backends/yuanbao.ts @@ -4,7 +4,7 @@ import { controlDescription, firstVisible, isUsableControl, operationalStatus } export const yuanbaoAdapter: BackendWebAdapter = { definition: yuanbaoDefinition, - stopDelayMs: 3_500, + stopCompletion: { minimumWaitMs: 250, quietWindowMs: 450, timeoutMs: 4_500 }, findEditor(document) { return firstVisible(document, yuanbaoDefinition.editorSelector); @@ -32,6 +32,12 @@ export const yuanbaoAdapter: BackendWebAdapter = { return control instanceof HTMLElement ? control : null; }, + isRecording(document) { + const microphone = this.findMicrophone(document); + return microphone?.getAttribute('aria-pressed') === 'true' || + /停止语音|stop\s*(voice|recording|input)/i.test(microphone ? controlDescription(microphone) : ''); + }, + serialize(editor) { const clone = editor.cloneNode(true) as HTMLElement; const text = clone.innerText ?? clone.textContent ?? ''; diff --git a/src/main/auto-start-policy.ts b/src/main/auto-start-policy.ts new file mode 100644 index 0000000..0f7a1ea --- /dev/null +++ b/src/main/auto-start-policy.ts @@ -0,0 +1,6 @@ +export type AutoStartAction = 'unchanged' | 'write-hidden-override' | 'remove-user-override'; + +export function resolveAutoStartAction(isPackaged: boolean, enabled: boolean): AutoStartAction { + if (!isPackaged) return 'unchanged'; + return enabled ? 'remove-user-override' : 'write-hidden-override'; +} diff --git a/src/main/auto-start-service.ts b/src/main/auto-start-service.ts index d4f4b5b..c8aee19 100644 --- a/src/main/auto-start-service.ts +++ b/src/main/auto-start-service.ts @@ -2,16 +2,15 @@ import { app } from 'electron'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; - -function desktopEscape(value: string): string { - return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('`', '\\`').replaceAll('$', '\\$')}"`; -} +import { resolveAutoStartAction } from './auto-start-policy'; export class AutoStartService { private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'meloasr.desktop'); apply(enabled: boolean): void { - if (!enabled) { + const action = resolveAutoStartAction(app.isPackaged, enabled); + if (action === 'unchanged') return; + if (action === 'write-hidden-override') { fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); fs.writeFileSync(this.filePath, [ '[Desktop Entry]', @@ -22,19 +21,6 @@ export class AutoStartService { ].join('\n'), { mode: 0o644 }); return; } - const args = app.isPackaged ? [] : [app.getAppPath()]; - const command = [process.execPath, ...args].map(desktopEscape).join(' '); - const contents = [ - '[Desktop Entry]', - 'Type=Application', - 'Name=MeloASR', - `Exec=${command}`, - 'Terminal=false', - 'X-GNOME-Autostart-enabled=true', - 'Comment=MeloASR', - '' - ].join('\n'); - fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); - fs.writeFileSync(this.filePath, contents, { mode: 0o644 }); + fs.rmSync(this.filePath, { force: true }); } } diff --git a/src/main/backend-manager.ts b/src/main/backend-manager.ts index 5bf1b68..eb73fdd 100644 --- a/src/main/backend-manager.ts +++ b/src/main/backend-manager.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, screen, session, type IpcMainEvent } from 'electron'; +import { app, BrowserWindow, screen, session, type IpcMainEvent } from 'electron'; import { EventEmitter } from 'node:events'; import path from 'node:path'; import { backendOwnsUrl, type BackendDefinition } from '../backends/contracts'; @@ -98,19 +98,6 @@ export class BackendManager extends EventEmitter { async showDebug(id = this.settings.get().backendId): Promise { const page = await this.ensure(id); const record = this.records.get(id)!; - if (!this.settings.get().loginNoticeShown[id]) { - await dialog.showMessageBox({ - type: 'info', - title: `${record.definition.label}登录`, - message: `请登录${record.definition.label}网页平台`, - detail: '登录完成后即可关闭此窗口。登录状态会保存在当前后端的独立会话中。', - buttons: ['继续'], - defaultId: 0 - }); - this.settings.update({ - loginNoticeShown: { ...this.settings.get().loginNoticeShown, [id]: true } - }); - } record.exposed = true; page.setSkipTaskbar(false); page.setBounds(this.debugBounds()); diff --git a/src/main/debug-login-flow.ts b/src/main/debug-login-flow.ts new file mode 100644 index 0000000..a406a9b --- /dev/null +++ b/src/main/debug-login-flow.ts @@ -0,0 +1,9 @@ +export interface DebugLoginAction { + action: 'prompt' | 'open'; + markShown: boolean; +} + +export function resolveDebugLoginAction(alreadyShown: boolean, confirmed: boolean): DebugLoginAction { + if (!alreadyShown && !confirmed) return { action: 'prompt', markShown: false }; + return { action: 'open', markShown: !alreadyShown }; +} diff --git a/src/main/fcitx-bridge.ts b/src/main/fcitx-bridge.ts index fef30e0..9099eb8 100644 --- a/src/main/fcitx-bridge.ts +++ b/src/main/fcitx-bridge.ts @@ -52,6 +52,7 @@ export class FcitxBridge extends EventEmitter { activeSession: string | null = null; private revision = 0; private shortcut = 'Control+Shift+space'; + private diagnosticLogging = false; constructor(options: { socketPath?: string; env?: NodeJS.ProcessEnv } = {}) { super(); @@ -83,9 +84,14 @@ export class FcitxBridge extends EventEmitter { fs.chmodSync(this.socketPath, 0o600); } - configure(shortcut: string): boolean { + configure(shortcut: string, diagnosticLogging = this.diagnosticLogging): boolean { this.shortcut = shortcut; - return this.write({ type: 'configure', shortcut }); + this.diagnosticLogging = diagnosticLogging; + return this.write({ type: 'configure', shortcut, diagnosticLogging }); + } + + setDiagnosticLogging(enabled: boolean): boolean { + return this.configure(this.shortcut, enabled); } requestInputStart(): boolean { diff --git a/src/main/index.ts b/src/main/index.ts index b4280fb..8a29063 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,12 +29,13 @@ async function bootstrap(): Promise { catch (error) { console.error('[开机自启动设置失败]', error); } const bridge = new FcitxBridge(); - bridge.configure(currentSettings.shortcut); + bridge.configure(currentSettings.shortcut, currentSettings.diagnosticLogging); const backends = new BackendManager(settings); const overlay = new OverlayController(settings); await overlay.create(); const sessions = new SessionController(backends, bridge, overlay); - const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() || null; + const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() || + 'https://github.com/Melorise/MeloASR'; const settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl); const settingsWindowReady = settingsWindow.create(); registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow }); @@ -61,7 +62,7 @@ async function bootstrap(): Promise { settings.on('changed', () => settingsWindow.publish()); await bridge.start(); - bridge.configure(settings.get().shortcut); + bridge.configure(settings.get().shortcut, settings.get().diagnosticLogging); await backends.ensure(settings.get().backendId); tray.refresh(); @@ -100,9 +101,9 @@ function registerIpc(services: Services): void { await backends.select(id); return settingsWindow.state(); }); - ipcMain.handle('settings:open-debug', async (event) => { + ipcMain.handle('settings:open-debug', async (event, confirmed: unknown) => { if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的窗口请求'); - await backends.showDebug(); + await settingsWindow.openDebug(confirmed === true); }); ipcMain.handle('settings:set-position', (event, point: Point, displayId: string) => { if (!settingsSender(event.sender.id) || !isPoint(point)) throw new Error('无效的悬浮球坐标'); @@ -118,12 +119,20 @@ function registerIpc(services: Services): void { if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的预览请求'); overlay.preview(); }); + ipcMain.handle('settings:begin-positioning', (event) => { + if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的位置调整请求'); + overlay.beginPositioning(); + }); + ipcMain.handle('settings:end-positioning', (event) => { + if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的位置调整请求'); + overlay.endPositioning(); + }); ipcMain.handle('settings:set-shortcut', (event, shortcut: unknown) => { if (!settingsSender(event.sender.id) || typeof shortcut !== 'string' || !validShortcut(shortcut)) { throw new Error('快捷键必须包含修饰键和一个主键'); } settings.update({ shortcut }); - bridge.configure(shortcut); + bridge.configure(shortcut, settings.get().diagnosticLogging); return settingsWindow.state(); }); ipcMain.handle('settings:set-auto-start', (event, enabled: unknown) => { @@ -138,6 +147,12 @@ function registerIpc(services: Services): void { overlay.reconcilePersistent(); return settingsWindow.state(); }); + ipcMain.handle('settings:set-diagnostic-logging', (event, enabled: unknown) => { + if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的日志设置'); + settings.update({ diagnosticLogging: enabled }); + bridge.setDiagnosticLogging(enabled); + return settingsWindow.state(); + }); ipcMain.handle('settings:open-repository', async (event) => { if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求'); await settingsWindow.openRepository(); @@ -161,6 +176,12 @@ function registerIpc(services: Services): void { sessions.backendError(payload.message || '后端启动失败'); } }); + ipcMain.on('backend-diagnostic', (event, payload: { backend?: string; stage?: string; [key: string]: unknown }) => { + if (typeof payload?.backend !== 'string' || !backends.senderIsActive(event, payload.backend)) return; + if (!settings.get().diagnosticLogging) return; + const { backend, stage, ...details } = payload; + console.info(`[MeloASR 网页诊断] backend=${backend} stage=${stage || 'unknown'} ${JSON.stringify(details)}`); + }); } function isPoint(value: unknown): value is Point { diff --git a/src/main/overlay-controller.ts b/src/main/overlay-controller.ts index d7a9e5b..6d4abe1 100644 --- a/src/main/overlay-controller.ts +++ b/src/main/overlay-controller.ts @@ -9,6 +9,7 @@ const SIZE = { width: 88, height: 88 }; export class OverlayController { window: BrowserWindow | null = null; private hideTimer: NodeJS.Timeout | null = null; + private positioning = false; constructor(private readonly settings: SettingsStore) {} @@ -18,6 +19,7 @@ export class OverlayController { show: false, frame: false, transparent: true, + type: 'toolbar', resizable: false, movable: false, alwaysOnTop: true, @@ -62,6 +64,23 @@ export class OverlayController { return this.applyPosition(presetPosition(display.workArea, SIZE, preset), String(display.id)); } + beginPositioning(): void { + if (!this.window || this.window.isDestroyed()) return; + this.positioning = true; + this.clearHideTimer(); + this.window.setIgnoreMouseEvents(true); + this.window.webContents.send('overlay-state', { state: 'idle' }); + this.window.showInactive(); + } + + endPositioning(): void { + if (!this.positioning) return; + this.positioning = false; + if (!this.window || this.window.isDestroyed()) return; + this.window.setIgnoreMouseEvents(false); + this.reconcilePersistent(); + } + showState(state: SessionState | 'done' | 'error'): void { if (!this.window || this.window.isDestroyed()) return; this.clearHideTimer(); @@ -83,6 +102,7 @@ export class OverlayController { reconcilePersistent(): void { if (!this.window || this.window.isDestroyed()) return; + if (this.positioning) return; if (this.settings.get().overlayPersistent) { this.showState('idle'); } else { diff --git a/src/main/session-controller.ts b/src/main/session-controller.ts index 7b49988..0ed7981 100644 --- a/src/main/session-controller.ts +++ b/src/main/session-controller.ts @@ -78,6 +78,7 @@ export class SessionController extends EventEmitter { cancel(message = '语音输入已取消'): void { if (!['starting', 'recording', 'stopping'].includes(this.state)) return; + console.warn(`[MeloASR 会话取消] state=${this.state} reason=${message}`); this.generation += 1; this.stopRequestedDuringStart = false; this.backends.activePage()?.webContents.send('backend-control', { @@ -109,6 +110,7 @@ export class SessionController extends EventEmitter { } backendError(message: string): void { + console.error(`[MeloASR 后端错误] state=${this.state} reason=${message}`); if (this.bridge.activeSession) this.bridge.cancel(); else this.bridge.rejectStart(message); this.state = 'idle'; this.mirroredText = ''; diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts index 0064cbe..46fc10f 100644 --- a/src/main/settings-store.ts +++ b/src/main/settings-store.ts @@ -9,6 +9,7 @@ const DEFAULT_SETTINGS: AppSettings = { shortcut: 'Control+Shift+space', autoStart: true, overlayPersistent: false, + diagnosticLogging: false, overlayPosition: null, loginNoticeShown: {} }; @@ -29,6 +30,7 @@ export class SettingsStore extends EventEmitter { overlayPosition: this.isPoint(parsed.overlayPosition) ? { x: Math.round(parsed.overlayPosition.x), y: Math.round(parsed.overlayPosition.y) } : null, + diagnosticLogging: parsed.diagnosticLogging === true, loginNoticeShown: parsed.loginNoticeShown ?? {} }; } catch { diff --git a/src/main/settings-window.ts b/src/main/settings-window.ts index 7257794..2d47851 100644 --- a/src/main/settings-window.ts +++ b/src/main/settings-window.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, screen, shell } from 'electron'; import path from 'node:path'; import type { SettingsViewState } from '../shared/contracts'; import type { BackendManager } from './backend-manager'; +import { resolveDebugLoginAction } from './debug-login-flow'; import type { OverlayController } from './overlay-controller'; import type { SessionController } from './session-controller'; import type { SettingsStore } from './settings-store'; @@ -39,6 +40,7 @@ export class SettingsWindow { event.preventDefault(); this.window?.hide(); }); + this.window.on('hide', () => this.overlay.endPositioning()); await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html')); } @@ -53,6 +55,23 @@ export class SettingsWindow { this.window.webContents.send('settings:state', this.state()); } + async openDebug(confirmed = false): Promise { + const current = this.settings.get(); + const definition = this.backends.activeDefinition(); + const decision = resolveDebugLoginAction(Boolean(current.loginNoticeShown[definition.id]), confirmed); + if (decision.action === 'prompt') { + this.show(); + this.window?.webContents.send('settings:login-notice', { backendLabel: definition.label }); + return; + } + if (decision.markShown) { + this.settings.update({ + loginNoticeShown: { ...current.loginNoticeShown, [definition.id]: true } + }); + } + await this.backends.showDebug(definition.id); + } + state(): SettingsViewState { const current = this.settings.get(); const position = this.overlay.position(current.overlayPosition); @@ -63,6 +82,7 @@ export class SettingsWindow { shortcut: current.shortcut, autoStart: current.autoStart, overlayPersistent: current.overlayPersistent, + diagnosticLogging: current.diagnosticLogging, overlayPosition: position, displays: screen.getAllDisplays().map((display, index) => ({ id: String(display.id), diff --git a/src/main/tray-controller.ts b/src/main/tray-controller.ts index e78ed8e..2b34be4 100644 --- a/src/main/tray-controller.ts +++ b/src/main/tray-controller.ts @@ -1,23 +1,21 @@ -import { Menu, Tray, nativeImage } from 'electron'; +import { app, Menu, Tray, nativeImage } from 'electron'; +import path from 'node:path'; import type { BackendManager } from './backend-manager'; import type { SettingsWindow } from './settings-window'; type TrayState = 'loading' | 'ready' | 'login-required' | 'error'; -const COLORS: Record = { - loading: '#e6a23c', - ready: '#22b573', - 'login-required': '#ef6c57', - error: '#d9465f' +const ICONS: Record = { + loading: 'tray-loading.png', + ready: 'tray-ready.png', + 'login-required': 'tray-login-required.png', + error: 'tray-error.png' }; function trayImage(state: TrayState): Electron.NativeImage { - const color = COLORS[state]; - const svg = ` - - - `; - return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`).resize({ width: 22, height: 22 }); + const image = nativeImage.createFromPath(path.join(app.getAppPath(), 'dist', 'assets', ICONS[state])); + image.setTemplateImage(false); + return image; } export class TrayController { @@ -47,7 +45,7 @@ export class TrayController { this.tray.setToolTip(`MeloASR · ${status.detail}`); this.tray.setContextMenu(Menu.buildFromTemplate([ { label: '设置', click: () => this.settingsWindow.show() }, - { label: '打开登录/调试页面', click: () => void this.backends.showDebug() }, + { label: '打开登录/调试页面', click: () => void this.settingsWindow.openDebug() }, { type: 'separator' }, { label: '重启', click: this.onRestart }, { label: '退出', click: this.onQuit } diff --git a/src/preload/backend.ts b/src/preload/backend.ts index 2cd7dad..78d7490 100644 --- a/src/preload/backend.ts +++ b/src/preload/backend.ts @@ -1,6 +1,8 @@ import { ipcRenderer } from 'electron'; import type { BackendControlCommand, BackendPageStatus, BackendTranscriptPayload } from '../backends/contracts'; import { getBackendWebAdapter } from '../backends/web-registry'; +import { shouldClickMicrophoneOnCancel } from './speech-session-state'; +import { shouldFinishAfterStop } from './stop-completion'; const backendId = process.argv .find((argument) => argument.startsWith('--meloasr-backend=')) @@ -14,9 +16,28 @@ let stopping = false; let lastSpeechText = ''; let emitScheduled = false; let stopTimer: ReturnType | undefined; +let cancelTimer: ReturnType | undefined; let statusTimer: ReturnType | undefined; let transcriptRevision = 0; let lastStatusKey = ''; +let lastTranscriptAt = 0; + +function controlSnapshot(element: HTMLElement | null): Record { + if (!element) return { found: false }; + return { + found: true, + tag: element.tagName, + ariaLabel: element.getAttribute('aria-label'), + ariaPressed: element.getAttribute('aria-pressed'), + title: element.getAttribute('title'), + disabled: element.matches('[disabled], [aria-disabled="true"]'), + className: String(element.className).slice(0, 240) + }; +} + +function diagnose(stage: string, details: Record = {}): void { + emit('backend-diagnostic', { stage, active, stopping, visibility: document.visibilityState, ...details }); +} function emit(channel: string, value: Record = {}): void { ipcRenderer.send(channel, { backend: backendId, ...value }); @@ -56,6 +77,8 @@ function emitCurrentEditorText(): void { if (text === lastSpeechText) return; lastSpeechText = text; transcriptRevision += 1; + lastTranscriptAt = Date.now(); + diagnose('transcript', { revision: transcriptRevision, textLength: text.length }); const payload: Omit = { text, revision: transcriptRevision }; emit('backend-transcript', payload); } @@ -91,18 +114,23 @@ function placeCaretAtEnd(editor: HTMLElement): void { function resetSession(): void { clearTimeout(stopTimer); + clearTimeout(cancelTimer); active = false; stopping = false; + lastTranscriptAt = 0; editorObserver?.disconnect(); + diagnose('session-reset'); } function fail(message: string): void { + diagnose('failure', { message }); resetSession(); emit('backend-start-error', { message }); emitPageStatus(true); } function startSpeech(): void { + diagnose('start-received'); if (!adapter) return; const status = adapter.detectPageStatus(document); if (!status.ready) { @@ -119,34 +147,98 @@ function startSpeech(): void { return; } + diagnose('start-targets', { + editorLength: adapter.serialize(editor).length, + microphone: controlSnapshot(microphone) + }); + clearTimeout(stopTimer); placeCaretAtEnd(editor); lastSpeechText = adapter.serialize(editor); transcriptRevision = 0; + lastTranscriptAt = Date.now(); active = true; stopping = false; observeEditor(editor); microphone.click(); + diagnose('start-clicked', { microphone: controlSnapshot(adapter.findMicrophone(document)) }); + window.setTimeout(() => diagnose('start-after-250ms', { + microphone: controlSnapshot(adapter.findMicrophone(document)), + editorLength: adapter.findEditor(document) ? adapter.serialize(adapter.findEditor(document)!).length : null + }), 250); + window.setTimeout(() => diagnose('start-after-1000ms', { + microphone: controlSnapshot(adapter.findMicrophone(document)), + editorLength: adapter.findEditor(document) ? adapter.serialize(adapter.findEditor(document)!).length : null + }), 1_000); } function stopSpeech(): void { + diagnose('stop-received', { microphone: controlSnapshot(adapter?.findMicrophone(document) ?? null) }); if (!active || stopping || !adapter) return; stopping = true; + const stoppedAt = Date.now(); + const wasRecordingAtStop = adapter.isRecording(document); adapter.findMicrophone(document)?.click(); - clearTimeout(stopTimer); - stopTimer = setTimeout(() => { + diagnose('stop-clicked', { microphone: controlSnapshot(adapter.findMicrophone(document)) }); + const finish = (): void => { emitCurrentEditorText(); resetSession(); emit('backend-session-ended', { text: lastSpeechText, revision: transcriptRevision }); emitPageStatus(true); - }, adapter.stopDelayMs); + }; + const checkCompletion = (): void => { + if (!active || !stopping || !adapter) return; + const now = Date.now(); + if (shouldFinishAfterStop({ + elapsedMs: now - stoppedAt, + quietMs: now - lastTranscriptAt, + wasRecordingAtStop, + isRecording: adapter.isRecording(document), + ...adapter.stopCompletion + })) { + diagnose('stop-complete', { elapsedMs: now - stoppedAt }); + finish(); + return; + } + stopTimer = setTimeout(checkCompletion, 75); + }; + clearTimeout(stopTimer); + stopTimer = setTimeout(checkCompletion, 75); } function cancelSpeech(): void { + diagnose('cancel-received', { microphone: controlSnapshot(adapter?.findMicrophone(document) ?? null) }); if (!active || !adapter) return; - adapter.findMicrophone(document)?.click(); - resetSession(); - lastSpeechText = ''; + if (!shouldClickMicrophoneOnCancel(active, stopping)) { + diagnose('cancel-click-skipped', { reason: 'stop-already-requested' }); + resetSession(); + lastSpeechText = ''; + return; + } + + // 网页的启动点击是异步的。若在按钮仍显示“语音输入”时再次点击, + // 元宝会在稍后仍进入录音,造成前后端会话分叉。 + const deadline = Date.now() + 1_500; + const stopWhenRecording = (): void => { + if (!active || !adapter) return; + const microphone = adapter.findMicrophone(document); + if (adapter.isRecording(document)) { + microphone?.click(); + diagnose('cancel-clicked', { microphone: controlSnapshot(adapter.findMicrophone(document)) }); + resetSession(); + lastSpeechText = ''; + return; + } + if (Date.now() >= deadline) { + diagnose('cancel-timeout', { microphone: controlSnapshot(microphone) }); + resetSession(); + lastSpeechText = ''; + return; + } + cancelTimer = setTimeout(stopWhenRecording, 50); + }; + clearTimeout(cancelTimer); + cancelTimer = setTimeout(stopWhenRecording, 50); } ipcRenderer.on('backend-control', (_event, command: BackendControlCommand) => { diff --git a/src/preload/settings.ts b/src/preload/settings.ts index 29e5d9f..5bcbe38 100644 --- a/src/preload/settings.ts +++ b/src/preload/settings.ts @@ -4,18 +4,25 @@ import type { Point, SettingsViewState } from '../shared/contracts'; contextBridge.exposeInMainWorld('meloSettings', { getState: (): Promise => ipcRenderer.invoke('settings:get-state'), setBackend: (id: string): Promise => ipcRenderer.invoke('settings:set-backend', id), - openDebug: (): Promise => ipcRenderer.invoke('settings:open-debug'), + openDebug: (confirmed = false): Promise => ipcRenderer.invoke('settings:open-debug', confirmed), setPosition: (position: Point, displayId: string): Promise => ipcRenderer.invoke('settings:set-position', position, displayId), setPreset: (preset: string, displayId: string): Promise => ipcRenderer.invoke('settings:set-preset', preset, displayId), previewOverlay: (): Promise => ipcRenderer.invoke('settings:preview-overlay'), + beginPositioning: (): Promise => ipcRenderer.invoke('settings:begin-positioning'), + endPositioning: (): Promise => ipcRenderer.invoke('settings:end-positioning'), setShortcut: (shortcut: string): Promise => ipcRenderer.invoke('settings:set-shortcut', shortcut), setAutoStart: (enabled: boolean): Promise => ipcRenderer.invoke('settings:set-auto-start', enabled), setOverlayPersistent: (enabled: boolean): Promise => ipcRenderer.invoke('settings:set-overlay-persistent', enabled), + setDiagnosticLogging: (enabled: boolean): Promise => + ipcRenderer.invoke('settings:set-diagnostic-logging', enabled), openRepository: (): Promise => ipcRenderer.invoke('settings:open-repository'), onState: (listener: (state: SettingsViewState) => void): void => { ipcRenderer.on('settings:state', (_event, state: SettingsViewState) => listener(state)); + }, + onLoginNotice: (listener: (backendLabel: string) => void): void => { + ipcRenderer.on('settings:login-notice', (_event, payload: { backendLabel: string }) => listener(payload.backendLabel)); } }); diff --git a/src/preload/speech-session-state.ts b/src/preload/speech-session-state.ts new file mode 100644 index 0000000..a819dc3 --- /dev/null +++ b/src/preload/speech-session-state.ts @@ -0,0 +1,3 @@ +export function shouldClickMicrophoneOnCancel(active: boolean, stopping: boolean): boolean { + return active && !stopping; +} diff --git a/src/preload/stop-completion.ts b/src/preload/stop-completion.ts new file mode 100644 index 0000000..fd4abb5 --- /dev/null +++ b/src/preload/stop-completion.ts @@ -0,0 +1,15 @@ +export interface StopCompletionInput { + readonly elapsedMs: number; + readonly quietMs: number; + readonly wasRecordingAtStop: boolean; + readonly isRecording: boolean; + readonly minimumWaitMs: number; + readonly quietWindowMs: number; + readonly timeoutMs: number; +} + +export function shouldFinishAfterStop(input: StopCompletionInput): boolean { + if (input.elapsedMs >= input.timeoutMs) return true; + if (input.wasRecordingAtStop && input.isRecording) return false; + return input.elapsedMs >= input.minimumWaitMs && input.quietMs >= input.quietWindowMs; +} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 93241ea..4de6930 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -5,15 +5,19 @@ declare global { meloSettings: { getState(): Promise; setBackend(id: string): Promise; - openDebug(): Promise; + openDebug(confirmed?: boolean): Promise; setPosition(position: Point, displayId: string): Promise; setPreset(preset: string, displayId: string): Promise; previewOverlay(): Promise; + beginPositioning(): Promise; + endPositioning(): Promise; setShortcut(shortcut: string): Promise; setAutoStart(enabled: boolean): Promise; setOverlayPersistent(enabled: boolean): Promise; + setDiagnosticLogging(enabled: boolean): Promise; openRepository(): Promise; onState(listener: (state: SettingsViewState) => void): void; + onLoginNotice(listener: (backendLabel: string) => void): void; }; meloOverlay: { toggleRecording(): void; diff --git a/src/renderer/settings.css b/src/renderer/settings.css index 84e88f1..e6db390 100644 --- a/src/renderer/settings.css +++ b/src/renderer/settings.css @@ -1,5 +1,6 @@ :root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; } * { box-sizing: border-box; } +[hidden] { display: none !important; } body { margin: 0; background: #f4f6fa; } header { display: flex; align-items: center; gap: 12px; padding: 22px 28px 12px; } header img { width: 44px; height: 44px; border-radius: 11px; } @@ -22,7 +23,22 @@ button:disabled { opacity: .55; cursor: default; } .warning { margin: 8px 0 12px; padding: 9px 11px; border-radius: 8px; color: #a84332; background: #fff0ed; } .switch { display: flex; align-items: center; gap: 10px; min-height: 40px; } .switch input { width: 17px; height: 17px; } -.screen { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 12px 0; padding: 15px; aspect-ratio: 16/5; border: 2px solid #b9c4d6; border-radius: 10px; background: #f7f9fc; } +.position-panel { max-width: 760px; } +.page-title { display: flex; align-items: center; gap: 12px; } +.page-title h2 { margin: 0; } +.page-title span { color: #788398; } +.position-screen { position: relative; height: 300px; margin: 16px 0; border: 2px solid #b9c4d6; border-radius: 10px; background: #f7f9fc; overflow: hidden; } +.position-ball { position: absolute; width: 28px; height: 28px; transform: translate(-50%, -50%); border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; box-shadow: 0 3px 10px rgba(52,118,237,.3); } +.position-controls { display: grid; grid-template-columns: 1fr 164px; align-items: center; gap: 24px; } +.direction-pad { display: grid; grid-template-columns: repeat(3, 48px); grid-template-rows: repeat(3, 38px); gap: 4px; justify-content: center; } +.direction-pad button { padding: 0; } +.direction-pad button[data-direction="up"] { grid-column: 2; } +.direction-pad button[data-direction="left"] { grid-column: 1; grid-row: 2; } +.direction-pad button[data-direction="center"] { grid-column: 2; grid-row: 2; } +.direction-pad button[data-direction="right"] { grid-column: 3; grid-row: 2; } +.direction-pad button[data-direction="down"] { grid-column: 2; grid-row: 3; } +.preset-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 18px 0 12px; padding: 12px; border: 1px solid #dbe2ee; border-radius: 10px; background: #f7f9fc; } +.preset-grid button { align-self: center; justify-self: center; width: 24px; height: 24px; padding: 0; border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; } .screen button { align-self: center; justify-self: center; width: 24px; height: 24px; padding: 0; border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; } .coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .coordinates label { display: grid; grid-template-columns: 18px 1fr; align-items: center; color: #5f6b80; } @@ -32,3 +48,9 @@ button:disabled { opacity: .55; cursor: default; } .shortcut { margin-left: auto; min-width: 180px; } .link { margin-left: auto; color: #286df0; background: transparent; } #message { min-height: 21px; color: #55708f; } +dialog { width: min(420px, calc(100vw - 48px)); padding: 0; border: 0; border-radius: 14px; color: inherit; box-shadow: 0 18px 55px rgba(20,32,55,.25); } +dialog::backdrop { background: rgba(18,27,43,.42); backdrop-filter: blur(2px); } +.modal-content { padding: 22px; } +.modal-content h2 { margin-bottom: 8px; } +.modal-content p { margin: 0; color: #5f6b80; } +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; } diff --git a/src/renderer/settings.html b/src/renderer/settings.html index b5a907a..850d47c 100644 --- a/src/renderer/settings.html +++ b/src/renderer/settings.html @@ -19,24 +19,14 @@

悬浮球

-
-
- - - -
-
- - -
-
方向键微调 1 像素,Shift + 方向键微调 10 像素
-
+

常规

按住说话快捷键
+
@@ -46,6 +36,37 @@
+
+

悬浮球位置

位置调整
+
+
+
+
+
+
+ + + +
+
+
+ + + +
+
方向键 1 像素,Shift + 方向键 10 像素
+
+
+ + + diff --git a/src/renderer/settings.ts b/src/renderer/settings.ts index fab87a7..2264b1c 100644 --- a/src/renderer/settings.ts +++ b/src/renderer/settings.ts @@ -11,6 +11,7 @@ interface SettingsViewState { shortcut: string; autoStart: boolean; overlayPersistent: boolean; + diagnosticLogging: boolean; overlayPosition: { x: number; y: number }; displays: Array<{ id: string; label: string; workArea: { x: number; y: number; width: number; height: number } }>; version: string; @@ -26,11 +27,19 @@ const y = document.querySelector('#position-y')!; const shortcut = document.querySelector('#shortcut')!; const autoStart = document.querySelector('#auto-start')!; const overlayPersistent = document.querySelector('#overlay-persistent')!; +const diagnosticLogging = document.querySelector('#diagnostic-logging')!; const version = document.querySelector('#version')!; const repository = document.querySelector('#repository')!; const message = document.querySelector('#message')!; +const settingsView = document.querySelector('body > main')!; +const positionView = document.querySelector('#position-view')!; +const positionScreen = document.querySelector('#position-screen')!; +const positionBall = document.querySelector('#position-ball')!; +const loginNotice = document.querySelector('#login-notice')!; +const loginNoticeTitle = document.querySelector('#login-notice-title')!; let state: SettingsViewState; let capturingShortcut = false; +let positionMode = false; function statusText(status: BackendRuntimeStatus): string { if (status.ready) return '已就绪'; @@ -59,14 +68,25 @@ function render(next: SettingsViewState): void { ? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? ''); x.value = String(next.overlayPosition.x); y.value = String(next.overlayPosition.y); + renderPositionPreview(next); shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut); autoStart.checked = next.autoStart; overlayPersistent.checked = next.overlayPersistent; + diagnosticLogging.checked = next.diagnosticLogging; version.textContent = next.version; repository.textContent = next.repositoryUrl ?? '尚未配置'; repository.disabled = !next.repositoryUrl; } +function renderPositionPreview(next: SettingsViewState): void { + const area = next.displays.find((item) => item.id === display.value)?.workArea ?? next.displays[0]?.workArea; + if (!area) return; + const px = Math.max(0, Math.min(area.width, next.overlayPosition.x - area.x)); + const py = Math.max(0, Math.min(area.height, next.overlayPosition.y - area.y)); + positionBall.style.left = `${(px / area.width) * 100}%`; + positionBall.style.top = `${(py / area.height) * 100}%`; +} + function displayShortcut(value: string): string { return value.replaceAll('Control', 'Ctrl').replaceAll('Super', 'Meta').replace(/\+space$/i, '+Space'); } @@ -101,13 +121,50 @@ async function applyPosition(): Promise { backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value))); document.querySelector('#open-debug')!.addEventListener('click', () => void window.meloSettings.openDebug()); +document.querySelector('#open-position')!.addEventListener('click', () => { + positionMode = true; + settingsView.style.display = 'none'; + positionView.style.display = 'grid'; + void window.meloSettings.beginPositioning(); + renderPositionPreview(state); +}); +document.querySelector('#back-settings')!.addEventListener('click', () => { + positionMode = false; + positionView.style.display = 'none'; + settingsView.style.display = 'grid'; + void window.meloSettings.endPositioning(); +}); +document.querySelector('#login-notice-cancel')!.addEventListener('click', () => loginNotice.close()); +document.querySelector('#login-notice-continue')!.addEventListener('click', () => { + loginNotice.close(); + void window.meloSettings.openDebug(true); +}); document.querySelectorAll('[data-preset]').forEach((button) => { button.addEventListener('click', () => void apply( () => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存' )); }); -document.querySelector('#apply-position')!.addEventListener('click', () => void applyPosition()); -document.querySelector('#preview-overlay')!.addEventListener('click', () => void window.meloSettings.previewOverlay()); + +async function nudge(direction: string): Promise { + if (direction === 'center') { + await apply(() => window.meloSettings.setPreset('middle-center', display.value), '位置已保存'); + return; + } + const step = 1; + const next = { x: Number(x.value), y: Number(y.value) }; + if (direction === 'left') next.x -= step; + if (direction === 'right') next.x += step; + if (direction === 'up') next.y -= step; + if (direction === 'down') next.y += step; + x.value = String(next.x); + y.value = String(next.y); + await applyPosition(); +} +document.querySelectorAll('[data-direction]').forEach((button) => { + button.addEventListener('click', () => void nudge(button.dataset.direction!)); +}); +x.addEventListener('change', () => void applyPosition()); +y.addEventListener('change', () => void applyPosition()); document.addEventListener('keydown', (event) => { if (capturingShortcut) { @@ -123,8 +180,8 @@ document.addEventListener('keydown', (event) => { void apply(() => window.meloSettings.setShortcut(value), '快捷键已保存'); return; } - if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return; - if ((event.target as HTMLElement).matches('select, button, input[type="checkbox"]')) return; + if (!positionMode || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return; + if ((event.target as HTMLElement).matches('select, input')) return; event.preventDefault(); const step = event.shiftKey ? 10 : 1; if (event.key === 'ArrowLeft') x.value = String(Number(x.value) - step); @@ -143,6 +200,16 @@ autoStart.addEventListener('change', () => void apply(() => window.meloSettings. overlayPersistent.addEventListener('change', () => void apply( () => window.meloSettings.setOverlayPersistent(overlayPersistent.checked) )); +diagnosticLogging.addEventListener('change', () => void apply( + () => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked) +)); repository.addEventListener('click', () => void window.meloSettings.openRepository()); window.meloSettings.onState(render); +window.meloSettings.onLoginNotice((backendLabel) => { + loginNoticeTitle.textContent = `登录${backendLabel}`; + if (!loginNotice.open) loginNotice.showModal(); +}); +window.addEventListener('beforeunload', () => { + if (positionMode) void window.meloSettings.endPositioning(); +}); void window.meloSettings.getState().then(render).catch((error: Error) => { message.textContent = error.message; }); diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 1a0bc15..1321797 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -12,6 +12,7 @@ export interface AppSettings { shortcut: string; autoStart: boolean; overlayPersistent: boolean; + diagnosticLogging: boolean; overlayPosition: Point | null; loginNoticeShown: Record; } @@ -43,6 +44,7 @@ export interface SettingsViewState { shortcut: string; autoStart: boolean; overlayPersistent: boolean; + diagnosticLogging: boolean; overlayPosition: Point; displays: DisplayInfo[]; version: string; diff --git a/test/auto-start-policy.test.ts b/test/auto-start-policy.test.ts new file mode 100644 index 0000000..b6cccbb --- /dev/null +++ b/test/auto-start-policy.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveAutoStartAction } from '../src/main/auto-start-policy'; + +test('开发模式不创建用户级自启动覆盖文件', () => { + assert.equal(resolveAutoStartAction(false, true), 'unchanged'); + assert.equal(resolveAutoStartAction(false, false), 'unchanged'); +}); + +test('安装包启用自启动时移除用户级覆盖,交由系统 desktop 文件启动', () => { + assert.equal(resolveAutoStartAction(true, true), 'remove-user-override'); +}); + +test('安装包关闭自启动时写入 Hidden 覆盖', () => { + assert.equal(resolveAutoStartAction(true, false), 'write-hidden-override'); +}); diff --git a/test/debug-login-flow.test.ts b/test/debug-login-flow.test.ts new file mode 100644 index 0000000..72b9529 --- /dev/null +++ b/test/debug-login-flow.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveDebugLoginAction } from '../src/main/debug-login-flow'; + +test('首次打开调试页时先显示应用内登录提示', () => { + assert.deepEqual(resolveDebugLoginAction(false, false), { action: 'prompt', markShown: false }); +}); + +test('用户确认或已经提示过时直接打开调试页', () => { + assert.deepEqual(resolveDebugLoginAction(false, true), { action: 'open', markShown: true }); + assert.deepEqual(resolveDebugLoginAction(true, false), { action: 'open', markShown: false }); +}); diff --git a/test/speech-session-state.test.ts b/test/speech-session-state.test.ts new file mode 100644 index 0000000..fd2bd74 --- /dev/null +++ b/test/speech-session-state.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldClickMicrophoneOnCancel } from '../src/preload/speech-session-state'; + +test('停止命令已经点击过麦克风后,取消不得再次点击并重新开启录音', () => { + assert.equal(shouldClickMicrophoneOnCancel(true, true), false); +}); + +test('录音期间直接取消时仍应点击麦克风停止网页录音', () => { + assert.equal(shouldClickMicrophoneOnCancel(true, false), true); +}); + +test('本地会话未激活时取消不得点击麦克风', () => { + assert.equal(shouldClickMicrophoneOnCancel(false, false), false); +}); diff --git a/test/status-decision.test.ts b/test/status-decision.test.ts new file mode 100644 index 0000000..0e2ce20 --- /dev/null +++ b/test/status-decision.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { decideOperationalStatus } from '../src/backends/status-decision'; + +test('登录入口存在时不得被编辑器和麦克风误判为已登录', () => { + assert.deepEqual(decideOperationalStatus(true, true, true), { + loginStatus: 'logged-out', + ready: false, + message: '尚未登录' + }); +}); + +test('对话页具备编辑器和麦克风时允许录音', () => { + assert.deepEqual(decideOperationalStatus(true, true, false), { + loginStatus: 'logged-in', + ready: true + }); +}); diff --git a/test/stop-completion.test.ts b/test/stop-completion.test.ts new file mode 100644 index 0000000..91ec8ea --- /dev/null +++ b/test/stop-completion.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldFinishAfterStop } from '../src/preload/stop-completion'; + +const base = { + elapsedMs: 700, + quietMs: 500, + wasRecordingAtStop: true, + isRecording: false, + minimumWaitMs: 250, + quietWindowMs: 450, + timeoutMs: 4_500 +}; + +test('网页仍在录音时不得提前提交', () => { + assert.equal(shouldFinishAfterStop({ ...base, isRecording: true }), false); +}); + +test('网页结束且最终文本静默窗口满足时立即提交', () => { + assert.equal(shouldFinishAfterStop(base), true); +}); + +test('网页结束后仍有文本修正时继续等待', () => { + assert.equal(shouldFinishAfterStop({ ...base, quietMs: 120 }), false); +}); + +test('网页状态未回切时由超时兜底结束', () => { + assert.equal(shouldFinishAfterStop({ ...base, elapsedMs: 4_500, isRecording: true }), true); +});