feat: 发布 MeloASR 0.1.7

整合 Linux Fcitx5 实时输入、网页后端、设置页、悬浮球、托盘和发行打包能力。

AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
2026-08-23 17:23:33 +08:00
co-authored by Codex
parent 009ec2c701
commit 4a5a8dc8dd
55 changed files with 679 additions and 165 deletions
+2
View File
@@ -5,3 +5,5 @@ dist-test/
release/ release/
packaging/out/ packaging/out/
chatgpt_amd64.deb chatgpt_amd64.deb
backend.js
settings.html
+1 -1
View File
@@ -2,7 +2,7 @@
## 项目范围 ## 项目范围
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.0`。 MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.7`。
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。 不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
+1 -1
View File
@@ -2,7 +2,7 @@
## 项目范围 ## 项目范围
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.0`。 MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.7`。
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。 不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
+21 -3
View File
@@ -1,12 +1,32 @@
# MeloASR # 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 ```text
@@ -75,5 +95,3 @@ npm run build
npm run build:fcitx5 npm run build:fcitx5
ctest --test-dir linux/fcitx5/build --output-on-failure ctest --test-dir linux/fcitx5/build --output-on-failure
``` ```
元宝仍需要使用具备语音输入权限的真实账号完成发布前端到端验证。不要把页面能加载等同于语音识别已经验证。
+28
View File
@@ -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;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <string>
#include <utility>
template <typename ClearPreedit, typename CommitText>
void finalizePreedit(const std::string &text, ClearPreedit &&clearPreedit,
CommitText &&commitText) {
std::forward<ClearPreedit>(clearPreedit)();
if (!text.empty()) {
std::forward<CommitText>(commitText)(text);
}
}
+19
View File
@@ -1,6 +1,10 @@
#include "protocolsession.h" #include "protocolsession.h"
#include "preeditfinalizer.h"
#include "keyeventclassifier.h"
#include <cassert> #include <cassert>
#include <string>
#include <vector>
int main() { int main() {
ProtocolSession session; ProtocolSession session;
@@ -18,4 +22,19 @@ int main() {
session.start("round-2"); session.start("round-2");
assert(!session.accept("round-1", 100)); assert(!session.accept("round-1", 100));
assert(session.accept("round-2", 1)); assert(session.accept("round-2", 1));
std::vector<std::string> finalizationEvents;
finalizePreedit(
"最终文本",
[&finalizationEvents]() { finalizationEvents.emplace_back("clear"); },
[&finalizationEvents](const std::string &text) {
finalizationEvents.emplace_back("commit:" + text);
});
assert((finalizationEvents == std::vector<std::string>{"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);
} }
+51 -36
View File
@@ -28,12 +28,16 @@
#include <fcitx/text.h> #include <fcitx/text.h>
#include "protocolsession.h" #include "protocolsession.h"
#include "preeditfinalizer.h"
#include "keyeventclassifier.h"
namespace { namespace {
constexpr int kProtocolVersion = 2; constexpr int kProtocolVersion = 2;
constexpr std::size_t kMaxBufferBytes = 1024 * 1024; constexpr std::size_t kMaxBufferBytes = 1024 * 1024;
#define MELOASR_DIAGNOSTIC_INFO if (!diagnosticLogging_) {} else FCITX_INFO()
class VoiceInputAddon final : public fcitx::AddonInstance { class VoiceInputAddon final : public fcitx::AddonInstance {
public: public:
explicit VoiceInputAddon(fcitx::Instance *instance) explicit VoiceInputAddon(fcitx::Instance *instance)
@@ -50,6 +54,8 @@ public:
fcitx::EventType::InputContextFocusOut, fcitx::EventType::InputContextFocusOut,
fcitx::EventWatcherPhase::PostInputMethod, fcitx::EventWatcherPhase::PostInputMethod,
[this](fcitx::Event &event) { [this](fcitx::Event &event) {
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext focusOut,取消当前会话 active="
<< session_.active() << " awaiting=" << awaitingStart_;
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext()); cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
}); });
focusInWatcher_ = instance_->watchEvent( focusInWatcher_ = instance_->watchEvent(
@@ -63,6 +69,8 @@ public:
fcitx::EventType::InputContextReset, fcitx::EventType::InputContextReset,
fcitx::EventWatcherPhase::PostInputMethod, fcitx::EventWatcherPhase::PostInputMethod,
[this](fcitx::Event &event) { [this](fcitx::Event &event) {
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext reset,取消当前会话 active="
<< session_.active() << " awaiting=" << awaitingStart_;
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext()); cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
}); });
destroyedWatcher_ = instance_->watchEvent( destroyedWatcher_ = instance_->watchEvent(
@@ -72,8 +80,9 @@ public:
auto *context = static_cast<fcitx::InputContextEvent &>(event).inputContext(); auto *context = static_cast<fcitx::InputContextEvent &>(event).inputContext();
if (context == focusedInputContext_) focusedInputContext_ = nullptr; if (context == focusedInputContext_) focusedInputContext_ = nullptr;
if (context == inputContext_) { if (context == inputContext_) {
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext destroyed,取消当前会话";
sendControl("request-cancel"); sendControl("request-cancel");
resetLocalState(false, false); resetLocalState(false);
} }
}); });
reconnectTimer_ = instance_->eventLoop().addTimeEvent( reconnectTimer_ = instance_->eventLoop().addTimeEvent(
@@ -91,24 +100,28 @@ public:
~VoiceInputAddon() override { ~VoiceInputAddon() override {
if (inputContext_) { if (inputContext_) {
clearPreedit(); clearPreedit();
restorePreeditState();
} }
disconnectSocket(); disconnectSocket();
} }
private: private:
void handleKeyEvent(fcitx::KeyEvent &event) { void handleKeyEvent(fcitx::KeyEvent &event) {
const bool isTriggerPress = !event.isRelease() && event.key().check(trigger_); const VoiceKeyAction action = classifyVoiceKeyEvent(
const bool isTriggerRelease = event.isRelease() && held_ && event.isRelease(), held_, event.key().check(trigger_),
event.rawKey().sym() == trigger_.sym(); 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(); event.filterAndAccept();
if (event.rawKey().states().test(fcitx::KeyState::Repeat)) return; return;
FCITX_INFO() << "网页语音输入:快捷键按下 held=" << held_ }
if (action == VoiceKeyAction::Start) {
event.filterAndAccept();
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键按下 held=" << held_
<< " awaiting=" << awaitingStart_ << " awaiting=" << awaitingStart_
<< " active=" << session_.active(); << " active=" << session_.active();
if (held_) return;
if (!ensureConnected()) { if (!ensureConnected()) {
FCITX_WARN() << "网页语音输入:Electron Socket 尚未就绪"; FCITX_WARN() << "网页语音输入:Electron Socket 尚未就绪";
return; return;
@@ -118,20 +131,20 @@ private:
return; return;
} }
if (isTriggerRelease) { if (action == VoiceKeyAction::Stop) {
event.filterAndAccept(); event.filterAndAccept();
FCITX_INFO() << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_ MELOASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_
<< " active=" << session_.active(); << " active=" << session_.active();
held_ = false; held_ = false;
if (awaitingStart_ || session_.active()) sendControl("request-stop"); if (awaitingStart_ || session_.active()) sendControl("request-stop");
return; return;
} }
if (!session_.active() && !awaitingStart_) return; if (action == VoiceKeyAction::Pass) return;
if (event.isRelease() || event.key().isModifier()) return;
if (event.key().check(FcitxKey_Escape)) { if (event.key().check(FcitxKey_Escape)) {
event.filterAndAccept(); event.filterAndAccept();
MELOASR_DIAGNOSTIC_INFO << "MeloASR:录音中收到 Escape,取消当前会话";
sendControl("request-cancel"); sendControl("request-cancel");
clearPreedit(); clearPreedit();
resetLocalState(false); 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"); sendControl("request-cancel");
clearPreedit(); clearPreedit();
resetLocalState(false); resetLocalState(false);
@@ -152,9 +168,6 @@ private:
return false; return false;
} }
inputContext_ = context; inputContext_ = context;
preeditWasEnabled_ = inputContext_->isPreeditEnabled();
preeditStateCaptured_ = true;
inputContext_->setEnablePreedit(true);
awaitingStart_ = true; awaitingStart_ = true;
sendControl("request-start"); sendControl("request-start");
return true; return true;
@@ -167,7 +180,7 @@ private:
if (probe > 0 || (probe < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))) { if (probe > 0 || (probe < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))) {
return true; return true;
} }
FCITX_INFO() << "网页语音输入:检测到失效的 Electron Socket,立即重连"; MELOASR_DIAGNOSTIC_INFO << "网页语音输入:检测到失效的 Electron Socket,立即重连";
handleDisconnect(); handleDisconnect();
} }
const char *runtimeDir = std::getenv("XDG_RUNTIME_DIR"); const char *runtimeDir = std::getenv("XDG_RUNTIME_DIR");
@@ -248,6 +261,9 @@ private:
return; return;
} }
if (type == "configure") { if (type == "configure") {
if (message.contains("diagnosticLogging") && message["diagnosticLogging"].is_boolean()) {
diagnosticLogging_ = message["diagnosticLogging"].get<bool>();
}
if (!message.contains("shortcut") || !message["shortcut"].is_string() || if (!message.contains("shortcut") || !message["shortcut"].is_string() ||
awaitingStart_ || session_.active()) return; awaitingStart_ || session_.active()) return;
fcitx::Key candidate(message["shortcut"].get<std::string>()); fcitx::Key candidate(message["shortcut"].get<std::string>());
@@ -256,7 +272,7 @@ private:
return; return;
} }
trigger_ = std::move(candidate); trigger_ = std::move(candidate);
FCITX_INFO() << "MeloASR:快捷键已更新为 " << trigger_.toString(); MELOASR_DIAGNOSTIC_INFO << "MeloASR:快捷键已更新为 " << trigger_.toString();
return; return;
} }
if (type == "activate") { if (type == "activate") {
@@ -270,14 +286,17 @@ private:
if (!message.contains("sessionId") || !message["sessionId"].is_string()) return; if (!message.contains("sessionId") || !message["sessionId"].is_string()) return;
session_.start(message["sessionId"].get<std::string>()); session_.start(message["sessionId"].get<std::string>());
awaitingStart_ = false; awaitingStart_ = false;
FCITX_INFO() << "网页语音输入:会话开始,clientPreedit=" MELOASR_DIAGNOSTIC_INFO << "网页语音输入:会话开始,clientPreedit="
<< inputContext_->capabilityFlags().test(fcitx::CapabilityFlag::Preedit); << inputContext_->capabilityFlags().test(fcitx::CapabilityFlag::Preedit);
setPreedit(""); // 新会话尚无文本;重复发布空 preedit 会使部分 frontend 立刻 reset。
return; return;
} }
if (type == "cancel") { if (type == "cancel") {
const std::string incoming = message.value("sessionId", std::string()); const std::string incoming = message.value("sessionId", std::string());
if (!incoming.empty() && !session_.matches(incoming)) return; if (!incoming.empty() && !session_.matches(incoming)) return;
MELOASR_DIAGNOSTIC_INFO << "MeloASR:收到 Electron cancel,sessionIdEmpty="
<< incoming.empty() << " message="
<< message.value("message", std::string());
clearPreedit(); clearPreedit();
resetLocalState(false); resetLocalState(false);
return; return;
@@ -288,14 +307,17 @@ private:
const std::int64_t incomingRevision = message["revision"].get<std::int64_t>(); const std::int64_t incomingRevision = message["revision"].get<std::int64_t>();
if (!session_.accept(message.value("sessionId", std::string()), incomingRevision)) return; if (!session_.accept(message.value("sessionId", std::string()), incomingRevision)) return;
currentText_ = message["text"].get<std::string>(); currentText_ = message["text"].get<std::string>();
FCITX_INFO() << "网页语音输入:收到 " << type << " revision=" MELOASR_DIAGNOSTIC_INFO << "网页语音输入:收到 " << type << " revision="
<< incomingRevision << " bytes=" << currentText_.size(); << incomingRevision << " bytes=" << currentText_.size();
setPreedit(currentText_); setPreedit(currentText_);
if (type == "finish") { if (type == "finish") {
if (inputContext_ && inputContext_->hasFocus() && !currentText_.empty()) { finalizePreedit(
inputContext_->commitString(currentText_); currentText_, [this]() { clearPreedit(); },
} [this](const std::string &text) {
clearPreedit(); if (inputContext_ && inputContext_->hasFocus()) {
inputContext_->commitString(text);
}
});
resetLocalState(false); resetLocalState(false);
} }
} }
@@ -325,15 +347,7 @@ private:
resetLocalState(false); resetLocalState(false);
} }
void restorePreeditState() { void resetLocalState(bool keepContext) {
if (inputContext_ && preeditStateCaptured_) {
inputContext_->setEnablePreedit(preeditWasEnabled_);
}
preeditStateCaptured_ = false;
}
void resetLocalState(bool keepContext, bool restorePreedit = true) {
if (restorePreedit) restorePreeditState();
held_ = false; held_ = false;
awaitingStart_ = false; awaitingStart_ = false;
session_.reset(); session_.reset();
@@ -361,6 +375,8 @@ private:
} }
void handleDisconnect() { void handleDisconnect() {
MELOASR_DIAGNOSTIC_INFO << "MeloASR:Electron Socket 断开,取消当前会话 active="
<< session_.active() << " awaiting=" << awaitingStart_;
clearPreedit(); clearPreedit();
resetLocalState(false); resetLocalState(false);
disconnectSocket(); disconnectSocket();
@@ -379,8 +395,7 @@ private:
fcitx::InputContext *focusedInputContext_ = nullptr; fcitx::InputContext *focusedInputContext_ = nullptr;
bool held_ = false; bool held_ = false;
bool awaitingStart_ = false; bool awaitingStart_ = false;
bool preeditStateCaptured_ = false; bool diagnosticLogging_ = false;
bool preeditWasEnabled_ = true;
ProtocolSession session_; ProtocolSession session_;
std::string currentText_; std::string currentText_;
int socketFd_ = -1; int socketFd_ = -1;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "meloasr", "name": "meloasr",
"version": "0.1.0", "version": "0.1.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meloasr", "name": "meloasr",
"version": "0.1.0", "version": "0.1.7",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"electron": "42.3.0", "electron": "42.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "meloasr", "name": "meloasr",
"productName": "MeloASR", "productName": "MeloASR",
"version": "0.1.0", "version": "0.1.7",
"private": true, "private": true,
"author": "MeloASR contributors", "author": "MeloASR contributors",
"description": "基于网页语音识别后端和 Fcitx5 的 Linux 实时语音输入工具", "description": "基于网页语音识别后端和 Fcitx5 的 Linux 实时语音输入工具",
+3 -3
View File
@@ -1,6 +1,6 @@
# Linux 分发打包 # Linux 分发打包
MeloASR 0.1.0 的系统包由两部分组成: MeloASR 0.1.7 的系统包由两部分组成:
- Electron 应用安装到 `/opt/meloasr`,命令入口为 `/usr/bin/meloasr`; - Electron 应用安装到 `/opt/meloasr`,命令入口为 `/usr/bin/meloasr`;
- Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。 - Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。
@@ -40,7 +40,7 @@ npm run build:linux:dir
packaging/deb/build-deb.sh packaging/deb/build-deb.sh
``` ```
产物位于 `packaging/out/meloasr_0.1.0_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。 产物位于 `packaging/out/meloasr_0.1.7_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
## rpm ## rpm
@@ -68,7 +68,7 @@ flake 使用 nixpkgs 的 Electron,Node 依赖由 `package-lock.json` 的 integ
## 发布前必须完成 ## 发布前必须完成
- 将所有 `example.invalid` 替换成正式仓库地址; - 发布前确认仓库地址和许可证元数据;
- 明确项目许可证,并替换临时的 `LicenseRef-Proprietary`/Nix license; - 明确项目许可证,并替换临时的 `LicenseRef-Proprietary`/Nix license;
- 生成 Arch 源码归档真实 SHA-256; - 生成 Arch 源码归档真实 SHA-256;
- 在干净的 Debian、RPM、Arch 和 Nix 构建环境逐一构建、安装、卸载; - 在干净的 Debian、RPM、Arch 和 Nix 构建环境逐一构建、安装、卸载;
+2 -2
View File
@@ -1,9 +1,9 @@
pkgname=meloasr pkgname=meloasr
pkgver=0.1.0 pkgver=0.1.7
pkgrel=1 pkgrel=1
pkgdesc='Web-backed voice input for Fcitx5' pkgdesc='Web-backed voice input for Fcitx5'
arch=('x86_64') arch=('x86_64')
url='https://example.invalid/meloasr' url='https://github.com/Melorise/MeloASR'
license=('custom') license=('custom')
depends=('fcitx5' 'gtk3' 'nss' 'libxss' 'mesa' 'alsa-lib') depends=('fcitx5' 'gtk3' 'nss' 'libxss' 'mesa' 'alsa-lib')
makedepends=('nodejs' 'npm' 'cmake' 'gcc' 'nlohmann-json') makedepends=('nodejs' 'npm' 'cmake' 'gcc' 'nlohmann-json')
+2 -3
View File
@@ -1,10 +1,9 @@
# Arch Linux 构建 # Arch Linux 构建
发布时把项目源码归档为 `meloasr-0.1.0.tar.gz`,与 `PKGBUILD` 放在同一目录,随后运行: 发布时把项目源码归档为 `meloasr-0.1.7.tar.gz`,与 `PKGBUILD` 放在同一目录,随后运行:
```bash ```bash
makepkg --syncdeps --cleanbuild makepkg --syncdeps --cleanbuild
``` ```
正式发布必须把 `source` 改为仓库的不可变 release URL,并以 `updpkgsums` 写入真实 SHA-256;仓库地址确定前保留的 `example.invalid` 和 `SKIP` 不能用于发布。 正式发布必须把 `source` 改为仓库的不可变 release URL,并以 `updpkgsums` 写入真实 SHA-256;当前源码包仍保留 `SKIP`,不能直接作为正式 Arch 发布源。
+1 -2
View File
@@ -1,3 +1,2 @@
#!/bin/sh #!/bin/sh
exec /opt/meloasr/meloasr "$@" exec /opt/meloasr/meloasr --ozone-platform=x11 "$@"
+2 -2
View File
@@ -10,12 +10,12 @@
<p>MeloASR connects supported web speech-recognition backends to the active Fcitx5 input context.</p> <p>MeloASR connects supported web speech-recognition backends to the active Fcitx5 input context.</p>
<p xml:lang="zh-CN">MeloASR 将支持的网页语音识别后端连接到当前 Fcitx5 输入上下文。</p> <p xml:lang="zh-CN">MeloASR 将支持的网页语音识别后端连接到当前 Fcitx5 输入上下文。</p>
</description> </description>
<url type="homepage">https://example.invalid/meloasr</url> <url type="homepage">https://github.com/Melorise/MeloASR</url>
<launchable type="desktop-id">meloasr.desktop</launchable> <launchable type="desktop-id">meloasr.desktop</launchable>
<provides> <provides>
<binary>meloasr</binary> <binary>meloasr</binary>
</provides> </provides>
<releases> <releases>
<release version="0.1.0" date="2026-08-23"/> <release version="0.1.7" date="2026-08-23"/>
</releases> </releases>
</component> </component>
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" 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)}" arch="${MELOASR_DEB_ARCH:-$(dpkg --print-architecture)}"
app_dir="${1:-${project_dir}/release/linux-unpacked}" app_dir="${1:-${project_dir}/release/linux-unpacked}"
work_dir="${project_dir}/packaging/out/deb-root" work_dir="${project_dir}/packaging/out/deb-root"
+2 -2
View File
@@ -12,7 +12,7 @@
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
pname = "meloasr"; pname = "meloasr";
version = "0.1.0"; version = "0.1.7";
src = ../..; src = ../..;
@@ -82,7 +82,7 @@ stdenv.mkDerivation (finalAttrs: {
meta = { meta = {
description = "Web-backed voice input for Fcitx5"; description = "Web-backed voice input for Fcitx5";
homepage = "https://example.invalid/meloasr"; homepage = "https://github.com/Melorise/MeloASR";
license = lib.licenses.unfreeRedistributable; license = lib.licenses.unfreeRedistributable;
mainProgram = "meloasr"; mainProgram = "meloasr";
platforms = lib.platforms.linux; platforms = lib.platforms.linux;
+1 -2
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" 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}" app_dir="${1:-${project_dir}/release/linux-unpacked}"
stage_dir="${project_dir}/packaging/out/rpm-root" stage_dir="${project_dir}/packaging/out/rpm-root"
top_dir="${project_dir}/packaging/out/rpmbuild" top_dir="${project_dir}/packaging/out/rpmbuild"
@@ -23,4 +23,3 @@ rpmbuild -bb \
"${project_dir}/packaging/rpm/meloasr-binary.spec" "${project_dir}/packaging/rpm/meloasr-binary.spec"
find "${top_dir}/RPMS" -type f -name '*.rpm' -exec cp -f {} "${project_dir}/packaging/out/" \; find "${top_dir}/RPMS" -type f -name '*.rpm' -exec cp -f {} "${project_dir}/packaging/out/" \;
+2 -2
View File
@@ -3,7 +3,7 @@ Version: %{meloasr_version}
Release: 1%{?dist} Release: 1%{?dist}
Summary: Web-backed voice input for Fcitx5 Summary: Web-backed voice input for Fcitx5
License: LicenseRef-Proprietary License: LicenseRef-Proprietary
URL: https://example.invalid/meloasr URL: https://github.com/Melorise/MeloASR
Source0: meloasr-root.tar.gz Source0: meloasr-root.tar.gz
Requires: fcitx5 Requires: fcitx5
@@ -36,5 +36,5 @@ tar -C %{buildroot} -xzf %{SOURCE0}
/etc/xdg/autostart/meloasr.desktop /etc/xdg/autostart/meloasr.desktop
%changelog %changelog
* Sun Aug 23 2026 MeloASR contributors - 0.1.0-1 * Sun Aug 23 2026 MeloASR contributors - 0.1.7-1
- Initial package - Initial package
+5 -1
View File
@@ -7,7 +7,11 @@ const copies = [
['src/renderer/settings.css', 'dist/renderer/settings.css'], ['src/renderer/settings.css', 'dist/renderer/settings.css'],
['src/renderer/overlay.html', 'dist/renderer/overlay.html'], ['src/renderer/overlay.html', 'dist/renderer/overlay.html'],
['src/renderer/overlay.css', 'dist/renderer/overlay.css'], ['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) { for (const [source, target] of copies) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect x="2" y="2" width="60" height="60" rx="18" fill="#22b573"/>
<rect x="24" y="10" width="16" height="27" rx="8" fill="#fff"/>
<path d="M17 30v4c0 8.3 6.7 15 15 15s15-6.7 15-15v-4M32 49v7M24 56h16"
fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

+7 -1
View File
@@ -22,9 +22,15 @@ export interface BackendPageStatus {
/** 仅在后端网页 preload 的渲染进程中调用。 */ /** 仅在后端网页 preload 的渲染进程中调用。 */
export interface BackendWebAdapter { export interface BackendWebAdapter {
readonly definition: BackendDefinition; readonly definition: BackendDefinition;
readonly stopDelayMs: number; /** 网页停止录音后的最短缓冲、文本静默窗口和异常兜底。 */
readonly stopCompletion: Readonly<{
minimumWaitMs: number;
quietWindowMs: number;
timeoutMs: number;
}>;
findEditor(document: Document): HTMLElement | null; findEditor(document: Document): HTMLElement | null;
findMicrophone(document: Document): HTMLElement | null; findMicrophone(document: Document): HTMLElement | null;
isRecording(document: Document): boolean;
serialize(editor: HTMLElement): string; serialize(editor: HTMLElement): string;
detectPageStatus(document: Document): BackendPageStatus; detectPageStatus(document: Document): BackendPageStatus;
} }
+6 -18
View File
@@ -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 { export function isVisible(element: Element | null): element is HTMLElement {
if (!(element instanceof HTMLElement)) return false; 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 { export function firstVisible(document: Document, selector: string): HTMLElement | null {
const elements = Array.from(document.querySelectorAll(selector)); 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 { export function controlDescription(element: Element): string {
@@ -22,13 +23,14 @@ export function controlDescription(element: Element): string {
element.getAttribute('title'), element.getAttribute('title'),
element.getAttribute('data-tooltip'), element.getAttribute('data-tooltip'),
element.textContent, element.textContent,
element.getAttribute('value'),
element.getAttribute('class'), element.getAttribute('class'),
element.querySelector('svg')?.getAttribute('class') element.querySelector('svg')?.getAttribute('class')
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
} }
export function findSemanticControl(document: Document, pattern: RegExp): HTMLElement | null { 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)); .find((element) => pattern.test(controlDescription(element)) && isUsableControl(element));
return control instanceof HTMLElement ? control : null; return control instanceof HTMLElement ? control : null;
} }
@@ -39,20 +41,6 @@ export function operationalStatus(
microphone: HTMLElement | null, microphone: HTMLElement | null,
loginPattern: RegExp loginPattern: RegExp
): BackendPageStatus { ): 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 loginControl = findSemanticControl(document, loginPattern);
const loginStatus: BackendLoginStatus = loginControl ? 'logged-out' : 'unknown'; return decideOperationalStatus(Boolean(editor), Boolean(microphone), Boolean(loginControl));
return {
loginStatus,
ready: false,
message: loginControl ? '尚未登录' : '页面尚未进入可语音输入状态'
};
} }
+8 -2
View File
@@ -1,10 +1,10 @@
import type { BackendWebAdapter } from './contracts'; import type { BackendWebAdapter } from './contracts';
import { qianwenDefinition } from './definitions'; import { qianwenDefinition } from './definitions';
import { firstVisible, isUsableControl, operationalStatus } from './dom'; import { controlDescription, firstVisible, isUsableControl, operationalStatus } from './dom';
export const qianwenAdapter: BackendWebAdapter = { export const qianwenAdapter: BackendWebAdapter = {
definition: qianwenDefinition, definition: qianwenDefinition,
stopDelayMs: 1_600, stopCompletion: { minimumWaitMs: 250, quietWindowMs: 350, timeoutMs: 2_500 },
findEditor(document) { findEditor(document) {
return firstVisible(document, qianwenDefinition.editorSelector); return firstVisible(document, qianwenDefinition.editorSelector);
@@ -18,6 +18,12 @@ export const qianwenAdapter: BackendWebAdapter = {
return control instanceof HTMLElement && isUsableControl(control) ? control : null; 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) { serialize(editor) {
return Array.from(editor.children) return Array.from(editor.children)
.filter((node) => node.nodeType === Node.ELEMENT_NODE) .filter((node) => node.nodeType === Node.ELEMENT_NODE)
+26
View File
@@ -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: '页面尚未进入可语音输入状态'
};
}
+7 -1
View File
@@ -4,7 +4,7 @@ import { controlDescription, firstVisible, isUsableControl, operationalStatus }
export const yuanbaoAdapter: BackendWebAdapter = { export const yuanbaoAdapter: BackendWebAdapter = {
definition: yuanbaoDefinition, definition: yuanbaoDefinition,
stopDelayMs: 3_500, stopCompletion: { minimumWaitMs: 250, quietWindowMs: 450, timeoutMs: 4_500 },
findEditor(document) { findEditor(document) {
return firstVisible(document, yuanbaoDefinition.editorSelector); return firstVisible(document, yuanbaoDefinition.editorSelector);
@@ -32,6 +32,12 @@ export const yuanbaoAdapter: BackendWebAdapter = {
return control instanceof HTMLElement ? control : null; 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) { serialize(editor) {
const clone = editor.cloneNode(true) as HTMLElement; const clone = editor.cloneNode(true) as HTMLElement;
const text = clone.innerText ?? clone.textContent ?? ''; const text = clone.innerText ?? clone.textContent ?? '';
+6
View File
@@ -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';
}
+5 -19
View File
@@ -2,16 +2,15 @@ import { app } from 'electron';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { resolveAutoStartAction } from './auto-start-policy';
function desktopEscape(value: string): string {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('`', '\\`').replaceAll('$', '\\$')}"`;
}
export class AutoStartService { export class AutoStartService {
private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'meloasr.desktop'); private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'meloasr.desktop');
apply(enabled: boolean): void { 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.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.writeFileSync(this.filePath, [ fs.writeFileSync(this.filePath, [
'[Desktop Entry]', '[Desktop Entry]',
@@ -22,19 +21,6 @@ export class AutoStartService {
].join('\n'), { mode: 0o644 }); ].join('\n'), { mode: 0o644 });
return; return;
} }
const args = app.isPackaged ? [] : [app.getAppPath()]; fs.rmSync(this.filePath, { force: true });
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 });
} }
} }
+1 -14
View File
@@ -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 { EventEmitter } from 'node:events';
import path from 'node:path'; import path from 'node:path';
import { backendOwnsUrl, type BackendDefinition } from '../backends/contracts'; import { backendOwnsUrl, type BackendDefinition } from '../backends/contracts';
@@ -98,19 +98,6 @@ export class BackendManager extends EventEmitter {
async showDebug(id = this.settings.get().backendId): Promise<void> { async showDebug(id = this.settings.get().backendId): Promise<void> {
const page = await this.ensure(id); const page = await this.ensure(id);
const record = this.records.get(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; record.exposed = true;
page.setSkipTaskbar(false); page.setSkipTaskbar(false);
page.setBounds(this.debugBounds()); page.setBounds(this.debugBounds());
+9
View File
@@ -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 };
}
+8 -2
View File
@@ -52,6 +52,7 @@ export class FcitxBridge extends EventEmitter {
activeSession: string | null = null; activeSession: string | null = null;
private revision = 0; private revision = 0;
private shortcut = 'Control+Shift+space'; private shortcut = 'Control+Shift+space';
private diagnosticLogging = false;
constructor(options: { socketPath?: string; env?: NodeJS.ProcessEnv } = {}) { constructor(options: { socketPath?: string; env?: NodeJS.ProcessEnv } = {}) {
super(); super();
@@ -83,9 +84,14 @@ export class FcitxBridge extends EventEmitter {
fs.chmodSync(this.socketPath, 0o600); fs.chmodSync(this.socketPath, 0o600);
} }
configure(shortcut: string): boolean { configure(shortcut: string, diagnosticLogging = this.diagnosticLogging): boolean {
this.shortcut = shortcut; 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 { requestInputStart(): boolean {
+27 -6
View File
@@ -29,12 +29,13 @@ async function bootstrap(): Promise<void> {
catch (error) { console.error('[开机自启动设置失败]', error); } catch (error) { console.error('[开机自启动设置失败]', error); }
const bridge = new FcitxBridge(); const bridge = new FcitxBridge();
bridge.configure(currentSettings.shortcut); bridge.configure(currentSettings.shortcut, currentSettings.diagnosticLogging);
const backends = new BackendManager(settings); const backends = new BackendManager(settings);
const overlay = new OverlayController(settings); const overlay = new OverlayController(settings);
await overlay.create(); await overlay.create();
const sessions = new SessionController(backends, bridge, overlay); 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 settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl);
const settingsWindowReady = settingsWindow.create(); const settingsWindowReady = settingsWindow.create();
registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow }); registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow });
@@ -61,7 +62,7 @@ async function bootstrap(): Promise<void> {
settings.on('changed', () => settingsWindow.publish()); settings.on('changed', () => settingsWindow.publish());
await bridge.start(); await bridge.start();
bridge.configure(settings.get().shortcut); bridge.configure(settings.get().shortcut, settings.get().diagnosticLogging);
await backends.ensure(settings.get().backendId); await backends.ensure(settings.get().backendId);
tray.refresh(); tray.refresh();
@@ -100,9 +101,9 @@ function registerIpc(services: Services): void {
await backends.select(id); await backends.select(id);
return settingsWindow.state(); 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('拒绝未授权的窗口请求'); 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) => { ipcMain.handle('settings:set-position', (event, point: Point, displayId: string) => {
if (!settingsSender(event.sender.id) || !isPoint(point)) throw new Error('无效的悬浮球坐标'); 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('拒绝未授权的预览请求'); if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的预览请求');
overlay.preview(); 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) => { ipcMain.handle('settings:set-shortcut', (event, shortcut: unknown) => {
if (!settingsSender(event.sender.id) || typeof shortcut !== 'string' || !validShortcut(shortcut)) { if (!settingsSender(event.sender.id) || typeof shortcut !== 'string' || !validShortcut(shortcut)) {
throw new Error('快捷键必须包含修饰键和一个主键'); throw new Error('快捷键必须包含修饰键和一个主键');
} }
settings.update({ shortcut }); settings.update({ shortcut });
bridge.configure(shortcut); bridge.configure(shortcut, settings.get().diagnosticLogging);
return settingsWindow.state(); return settingsWindow.state();
}); });
ipcMain.handle('settings:set-auto-start', (event, enabled: unknown) => { ipcMain.handle('settings:set-auto-start', (event, enabled: unknown) => {
@@ -138,6 +147,12 @@ function registerIpc(services: Services): void {
overlay.reconcilePersistent(); overlay.reconcilePersistent();
return settingsWindow.state(); 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) => { ipcMain.handle('settings:open-repository', async (event) => {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求'); if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求');
await settingsWindow.openRepository(); await settingsWindow.openRepository();
@@ -161,6 +176,12 @@ function registerIpc(services: Services): void {
sessions.backendError(payload.message || '后端启动失败'); 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 { function isPoint(value: unknown): value is Point {
+20
View File
@@ -9,6 +9,7 @@ const SIZE = { width: 88, height: 88 };
export class OverlayController { export class OverlayController {
window: BrowserWindow | null = null; window: BrowserWindow | null = null;
private hideTimer: NodeJS.Timeout | null = null; private hideTimer: NodeJS.Timeout | null = null;
private positioning = false;
constructor(private readonly settings: SettingsStore) {} constructor(private readonly settings: SettingsStore) {}
@@ -18,6 +19,7 @@ export class OverlayController {
show: false, show: false,
frame: false, frame: false,
transparent: true, transparent: true,
type: 'toolbar',
resizable: false, resizable: false,
movable: false, movable: false,
alwaysOnTop: true, alwaysOnTop: true,
@@ -62,6 +64,23 @@ export class OverlayController {
return this.applyPosition(presetPosition(display.workArea, SIZE, preset), String(display.id)); 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 { showState(state: SessionState | 'done' | 'error'): void {
if (!this.window || this.window.isDestroyed()) return; if (!this.window || this.window.isDestroyed()) return;
this.clearHideTimer(); this.clearHideTimer();
@@ -83,6 +102,7 @@ export class OverlayController {
reconcilePersistent(): void { reconcilePersistent(): void {
if (!this.window || this.window.isDestroyed()) return; if (!this.window || this.window.isDestroyed()) return;
if (this.positioning) return;
if (this.settings.get().overlayPersistent) { if (this.settings.get().overlayPersistent) {
this.showState('idle'); this.showState('idle');
} else { } else {
+2
View File
@@ -78,6 +78,7 @@ export class SessionController extends EventEmitter {
cancel(message = '语音输入已取消'): void { cancel(message = '语音输入已取消'): void {
if (!['starting', 'recording', 'stopping'].includes(this.state)) return; if (!['starting', 'recording', 'stopping'].includes(this.state)) return;
console.warn(`[MeloASR 会话取消] state=${this.state} reason=${message}`);
this.generation += 1; this.generation += 1;
this.stopRequestedDuringStart = false; this.stopRequestedDuringStart = false;
this.backends.activePage()?.webContents.send('backend-control', { this.backends.activePage()?.webContents.send('backend-control', {
@@ -109,6 +110,7 @@ export class SessionController extends EventEmitter {
} }
backendError(message: string): void { backendError(message: string): void {
console.error(`[MeloASR 后端错误] state=${this.state} reason=${message}`);
if (this.bridge.activeSession) this.bridge.cancel(); else this.bridge.rejectStart(message); if (this.bridge.activeSession) this.bridge.cancel(); else this.bridge.rejectStart(message);
this.state = 'idle'; this.state = 'idle';
this.mirroredText = ''; this.mirroredText = '';
+2
View File
@@ -9,6 +9,7 @@ const DEFAULT_SETTINGS: AppSettings = {
shortcut: 'Control+Shift+space', shortcut: 'Control+Shift+space',
autoStart: true, autoStart: true,
overlayPersistent: false, overlayPersistent: false,
diagnosticLogging: false,
overlayPosition: null, overlayPosition: null,
loginNoticeShown: {} loginNoticeShown: {}
}; };
@@ -29,6 +30,7 @@ export class SettingsStore extends EventEmitter {
overlayPosition: this.isPoint(parsed.overlayPosition) ? { overlayPosition: this.isPoint(parsed.overlayPosition) ? {
x: Math.round(parsed.overlayPosition.x), y: Math.round(parsed.overlayPosition.y) x: Math.round(parsed.overlayPosition.x), y: Math.round(parsed.overlayPosition.y)
} : null, } : null,
diagnosticLogging: parsed.diagnosticLogging === true,
loginNoticeShown: parsed.loginNoticeShown ?? {} loginNoticeShown: parsed.loginNoticeShown ?? {}
}; };
} catch { } catch {
+20
View File
@@ -2,6 +2,7 @@ import { app, BrowserWindow, screen, shell } from 'electron';
import path from 'node:path'; import path from 'node:path';
import type { SettingsViewState } from '../shared/contracts'; import type { SettingsViewState } from '../shared/contracts';
import type { BackendManager } from './backend-manager'; import type { BackendManager } from './backend-manager';
import { resolveDebugLoginAction } from './debug-login-flow';
import type { OverlayController } from './overlay-controller'; import type { OverlayController } from './overlay-controller';
import type { SessionController } from './session-controller'; import type { SessionController } from './session-controller';
import type { SettingsStore } from './settings-store'; import type { SettingsStore } from './settings-store';
@@ -39,6 +40,7 @@ export class SettingsWindow {
event.preventDefault(); event.preventDefault();
this.window?.hide(); this.window?.hide();
}); });
this.window.on('hide', () => this.overlay.endPositioning());
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html')); 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()); this.window.webContents.send('settings:state', this.state());
} }
async openDebug(confirmed = false): Promise<void> {
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 { state(): SettingsViewState {
const current = this.settings.get(); const current = this.settings.get();
const position = this.overlay.position(current.overlayPosition); const position = this.overlay.position(current.overlayPosition);
@@ -63,6 +82,7 @@ export class SettingsWindow {
shortcut: current.shortcut, shortcut: current.shortcut,
autoStart: current.autoStart, autoStart: current.autoStart,
overlayPersistent: current.overlayPersistent, overlayPersistent: current.overlayPersistent,
diagnosticLogging: current.diagnosticLogging,
overlayPosition: position, overlayPosition: position,
displays: screen.getAllDisplays().map((display, index) => ({ displays: screen.getAllDisplays().map((display, index) => ({
id: String(display.id), id: String(display.id),
+11 -13
View File
@@ -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 { BackendManager } from './backend-manager';
import type { SettingsWindow } from './settings-window'; import type { SettingsWindow } from './settings-window';
type TrayState = 'loading' | 'ready' | 'login-required' | 'error'; type TrayState = 'loading' | 'ready' | 'login-required' | 'error';
const COLORS: Record<TrayState, string> = { const ICONS: Record<TrayState, string> = {
loading: '#e6a23c', loading: 'tray-loading.png',
ready: '#22b573', ready: 'tray-ready.png',
'login-required': '#ef6c57', 'login-required': 'tray-login-required.png',
error: '#d9465f' error: 'tray-error.png'
}; };
function trayImage(state: TrayState): Electron.NativeImage { function trayImage(state: TrayState): Electron.NativeImage {
const color = COLORS[state]; const image = nativeImage.createFromPath(path.join(app.getAppPath(), 'dist', 'assets', ICONS[state]));
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"> image.setTemplateImage(false);
<circle cx="16" cy="16" r="14" fill="${color}"/> return image;
<path d="M8 19v-6h3v6zm5 4V9h3v14zm5-2V11h3v10zm5-3v-4h3v4z" fill="white"/>
</svg>`;
return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`).resize({ width: 22, height: 22 });
} }
export class TrayController { export class TrayController {
@@ -47,7 +45,7 @@ export class TrayController {
this.tray.setToolTip(`MeloASR · ${status.detail}`); this.tray.setToolTip(`MeloASR · ${status.detail}`);
this.tray.setContextMenu(Menu.buildFromTemplate([ this.tray.setContextMenu(Menu.buildFromTemplate([
{ label: '设置', click: () => this.settingsWindow.show() }, { label: '设置', click: () => this.settingsWindow.show() },
{ label: '打开登录/调试页面', click: () => void this.backends.showDebug() }, { label: '打开登录/调试页面', click: () => void this.settingsWindow.openDebug() },
{ type: 'separator' }, { type: 'separator' },
{ label: '重启', click: this.onRestart }, { label: '重启', click: this.onRestart },
{ label: '退出', click: this.onQuit } { label: '退出', click: this.onQuit }
+98 -6
View File
@@ -1,6 +1,8 @@
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import type { BackendControlCommand, BackendPageStatus, BackendTranscriptPayload } from '../backends/contracts'; import type { BackendControlCommand, BackendPageStatus, BackendTranscriptPayload } from '../backends/contracts';
import { getBackendWebAdapter } from '../backends/web-registry'; import { getBackendWebAdapter } from '../backends/web-registry';
import { shouldClickMicrophoneOnCancel } from './speech-session-state';
import { shouldFinishAfterStop } from './stop-completion';
const backendId = process.argv const backendId = process.argv
.find((argument) => argument.startsWith('--meloasr-backend=')) .find((argument) => argument.startsWith('--meloasr-backend='))
@@ -14,9 +16,28 @@ let stopping = false;
let lastSpeechText = ''; let lastSpeechText = '';
let emitScheduled = false; let emitScheduled = false;
let stopTimer: ReturnType<typeof setTimeout> | undefined; let stopTimer: ReturnType<typeof setTimeout> | undefined;
let cancelTimer: ReturnType<typeof setTimeout> | undefined;
let statusTimer: ReturnType<typeof setTimeout> | undefined; let statusTimer: ReturnType<typeof setTimeout> | undefined;
let transcriptRevision = 0; let transcriptRevision = 0;
let lastStatusKey = ''; let lastStatusKey = '';
let lastTranscriptAt = 0;
function controlSnapshot(element: HTMLElement | null): Record<string, unknown> {
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<string, unknown> = {}): void {
emit('backend-diagnostic', { stage, active, stopping, visibility: document.visibilityState, ...details });
}
function emit(channel: string, value: Record<string, unknown> = {}): void { function emit(channel: string, value: Record<string, unknown> = {}): void {
ipcRenderer.send(channel, { backend: backendId, ...value }); ipcRenderer.send(channel, { backend: backendId, ...value });
@@ -56,6 +77,8 @@ function emitCurrentEditorText(): void {
if (text === lastSpeechText) return; if (text === lastSpeechText) return;
lastSpeechText = text; lastSpeechText = text;
transcriptRevision += 1; transcriptRevision += 1;
lastTranscriptAt = Date.now();
diagnose('transcript', { revision: transcriptRevision, textLength: text.length });
const payload: Omit<BackendTranscriptPayload, 'backend'> = { text, revision: transcriptRevision }; const payload: Omit<BackendTranscriptPayload, 'backend'> = { text, revision: transcriptRevision };
emit('backend-transcript', payload); emit('backend-transcript', payload);
} }
@@ -91,18 +114,23 @@ function placeCaretAtEnd(editor: HTMLElement): void {
function resetSession(): void { function resetSession(): void {
clearTimeout(stopTimer); clearTimeout(stopTimer);
clearTimeout(cancelTimer);
active = false; active = false;
stopping = false; stopping = false;
lastTranscriptAt = 0;
editorObserver?.disconnect(); editorObserver?.disconnect();
diagnose('session-reset');
} }
function fail(message: string): void { function fail(message: string): void {
diagnose('failure', { message });
resetSession(); resetSession();
emit('backend-start-error', { message }); emit('backend-start-error', { message });
emitPageStatus(true); emitPageStatus(true);
} }
function startSpeech(): void { function startSpeech(): void {
diagnose('start-received');
if (!adapter) return; if (!adapter) return;
const status = adapter.detectPageStatus(document); const status = adapter.detectPageStatus(document);
if (!status.ready) { if (!status.ready) {
@@ -119,34 +147,98 @@ function startSpeech(): void {
return; return;
} }
diagnose('start-targets', {
editorLength: adapter.serialize(editor).length,
microphone: controlSnapshot(microphone)
});
clearTimeout(stopTimer); clearTimeout(stopTimer);
placeCaretAtEnd(editor); placeCaretAtEnd(editor);
lastSpeechText = adapter.serialize(editor); lastSpeechText = adapter.serialize(editor);
transcriptRevision = 0; transcriptRevision = 0;
lastTranscriptAt = Date.now();
active = true; active = true;
stopping = false; stopping = false;
observeEditor(editor); observeEditor(editor);
microphone.click(); 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 { function stopSpeech(): void {
diagnose('stop-received', { microphone: controlSnapshot(adapter?.findMicrophone(document) ?? null) });
if (!active || stopping || !adapter) return; if (!active || stopping || !adapter) return;
stopping = true; stopping = true;
const stoppedAt = Date.now();
const wasRecordingAtStop = adapter.isRecording(document);
adapter.findMicrophone(document)?.click(); adapter.findMicrophone(document)?.click();
clearTimeout(stopTimer); diagnose('stop-clicked', { microphone: controlSnapshot(adapter.findMicrophone(document)) });
stopTimer = setTimeout(() => { const finish = (): void => {
emitCurrentEditorText(); emitCurrentEditorText();
resetSession(); resetSession();
emit('backend-session-ended', { text: lastSpeechText, revision: transcriptRevision }); emit('backend-session-ended', { text: lastSpeechText, revision: transcriptRevision });
emitPageStatus(true); 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 { function cancelSpeech(): void {
diagnose('cancel-received', { microphone: controlSnapshot(adapter?.findMicrophone(document) ?? null) });
if (!active || !adapter) return; if (!active || !adapter) return;
adapter.findMicrophone(document)?.click(); if (!shouldClickMicrophoneOnCancel(active, stopping)) {
resetSession(); diagnose('cancel-click-skipped', { reason: 'stop-already-requested' });
lastSpeechText = ''; 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) => { ipcRenderer.on('backend-control', (_event, command: BackendControlCommand) => {
+8 -1
View File
@@ -4,18 +4,25 @@ import type { Point, SettingsViewState } from '../shared/contracts';
contextBridge.exposeInMainWorld('meloSettings', { contextBridge.exposeInMainWorld('meloSettings', {
getState: (): Promise<SettingsViewState> => ipcRenderer.invoke('settings:get-state'), getState: (): Promise<SettingsViewState> => ipcRenderer.invoke('settings:get-state'),
setBackend: (id: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-backend', id), setBackend: (id: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-backend', id),
openDebug: (): Promise<void> => ipcRenderer.invoke('settings:open-debug'), openDebug: (confirmed = false): Promise<void> => ipcRenderer.invoke('settings:open-debug', confirmed),
setPosition: (position: Point, displayId: string): Promise<SettingsViewState> => setPosition: (position: Point, displayId: string): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-position', position, displayId), ipcRenderer.invoke('settings:set-position', position, displayId),
setPreset: (preset: string, displayId: string): Promise<SettingsViewState> => setPreset: (preset: string, displayId: string): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-preset', preset, displayId), ipcRenderer.invoke('settings:set-preset', preset, displayId),
previewOverlay: (): Promise<void> => ipcRenderer.invoke('settings:preview-overlay'), previewOverlay: (): Promise<void> => ipcRenderer.invoke('settings:preview-overlay'),
beginPositioning: (): Promise<void> => ipcRenderer.invoke('settings:begin-positioning'),
endPositioning: (): Promise<void> => ipcRenderer.invoke('settings:end-positioning'),
setShortcut: (shortcut: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-shortcut', shortcut), setShortcut: (shortcut: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-shortcut', shortcut),
setAutoStart: (enabled: boolean): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-auto-start', enabled), setAutoStart: (enabled: boolean): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-auto-start', enabled),
setOverlayPersistent: (enabled: boolean): Promise<SettingsViewState> => setOverlayPersistent: (enabled: boolean): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-overlay-persistent', enabled), ipcRenderer.invoke('settings:set-overlay-persistent', enabled),
setDiagnosticLogging: (enabled: boolean): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-diagnostic-logging', enabled),
openRepository: (): Promise<void> => ipcRenderer.invoke('settings:open-repository'), openRepository: (): Promise<void> => ipcRenderer.invoke('settings:open-repository'),
onState: (listener: (state: SettingsViewState) => void): void => { onState: (listener: (state: SettingsViewState) => void): void => {
ipcRenderer.on('settings:state', (_event, state: SettingsViewState) => listener(state)); 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));
} }
}); });
+3
View File
@@ -0,0 +1,3 @@
export function shouldClickMicrophoneOnCancel(active: boolean, stopping: boolean): boolean {
return active && !stopping;
}
+15
View File
@@ -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;
}
+5 -1
View File
@@ -5,15 +5,19 @@ declare global {
meloSettings: { meloSettings: {
getState(): Promise<SettingsViewState>; getState(): Promise<SettingsViewState>;
setBackend(id: string): Promise<SettingsViewState>; setBackend(id: string): Promise<SettingsViewState>;
openDebug(): Promise<void>; openDebug(confirmed?: boolean): Promise<void>;
setPosition(position: Point, displayId: string): Promise<SettingsViewState>; setPosition(position: Point, displayId: string): Promise<SettingsViewState>;
setPreset(preset: string, displayId: string): Promise<SettingsViewState>; setPreset(preset: string, displayId: string): Promise<SettingsViewState>;
previewOverlay(): Promise<void>; previewOverlay(): Promise<void>;
beginPositioning(): Promise<void>;
endPositioning(): Promise<void>;
setShortcut(shortcut: string): Promise<SettingsViewState>; setShortcut(shortcut: string): Promise<SettingsViewState>;
setAutoStart(enabled: boolean): Promise<SettingsViewState>; setAutoStart(enabled: boolean): Promise<SettingsViewState>;
setOverlayPersistent(enabled: boolean): Promise<SettingsViewState>; setOverlayPersistent(enabled: boolean): Promise<SettingsViewState>;
setDiagnosticLogging(enabled: boolean): Promise<SettingsViewState>;
openRepository(): Promise<void>; openRepository(): Promise<void>;
onState(listener: (state: SettingsViewState) => void): void; onState(listener: (state: SettingsViewState) => void): void;
onLoginNotice(listener: (backendLabel: string) => void): void;
}; };
meloOverlay: { meloOverlay: {
toggleRecording(): void; toggleRecording(): void;
+23 -1
View File
@@ -1,5 +1,6 @@
:root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; } :root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: #f4f6fa; } body { margin: 0; background: #f4f6fa; }
header { display: flex; align-items: center; gap: 12px; padding: 22px 28px 12px; } header { display: flex; align-items: center; gap: 12px; padding: 22px 28px 12px; }
header img { width: 44px; height: 44px; border-radius: 11px; } 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; } .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 { display: flex; align-items: center; gap: 10px; min-height: 40px; }
.switch input { width: 17px; height: 17px; } .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; } .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 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.coordinates label { display: grid; grid-template-columns: 18px 1fr; align-items: center; color: #5f6b80; } .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; } .shortcut { margin-left: auto; min-width: 180px; }
.link { margin-left: auto; color: #286df0; background: transparent; } .link { margin-left: auto; color: #286df0; background: transparent; }
#message { min-height: 21px; color: #55708f; } #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; }
+33 -12
View File
@@ -19,24 +19,14 @@
<section> <section>
<h2>悬浮球</h2> <h2>悬浮球</h2>
<label class="switch"><input id="overlay-persistent" type="checkbox"><span>始终显示悬浮球</span></label> <label class="switch"><input id="overlay-persistent" type="checkbox"><span>始终显示悬浮球</span></label>
<div class="row"><label for="display">屏幕</label><select id="display"></select></div> <button id="open-position">调整悬浮球位置</button>
<div class="screen" aria-label="九宫格位置预设">
<button data-preset="top-left" title="左上"></button><button data-preset="top-center" title="顶部居中"></button><button data-preset="top-right" title="右上"></button>
<button data-preset="middle-left" title="左侧居中"></button><button data-preset="middle-center" title="屏幕中央"></button><button data-preset="middle-right" title="右侧居中"></button>
<button data-preset="bottom-left" title="左下"></button><button data-preset="bottom-center" title="底部居中"></button><button data-preset="bottom-right" title="右下"></button>
</div>
<div class="coordinates">
<label for="position-x">X <input id="position-x" type="number" step="1"></label>
<label for="position-y">Y <input id="position-y" type="number" step="1"></label>
</div>
<div class="hint">方向键微调 1 像素,Shift + 方向键微调 10 像素</div>
<div class="buttons"><button id="apply-position" class="primary">应用坐标</button><button id="preview-overlay">预览</button></div>
</section> </section>
<section> <section>
<h2>常规</h2> <h2>常规</h2>
<div class="row"><span>按住说话快捷键</span><button id="shortcut" class="shortcut"></button></div> <div class="row"><span>按住说话快捷键</span><button id="shortcut" class="shortcut"></button></div>
<label class="switch"><input id="auto-start" type="checkbox"><span>开机自动启动</span></label> <label class="switch"><input id="auto-start" type="checkbox"><span>开机自动启动</span></label>
<label class="switch"><input id="diagnostic-logging" type="checkbox"><span>记录诊断日志</span></label>
</section> </section>
<section> <section>
@@ -46,6 +36,37 @@
</section> </section>
<div id="message" role="status"></div> <div id="message" role="status"></div>
</main> </main>
<main id="position-view" style="display: none">
<div class="page-title"><button id="back-settings" title="返回设置">返回</button><div><h2>悬浮球位置</h2><span>位置调整</span></div></div>
<section class="position-panel">
<div class="row"><label for="display">屏幕</label><select id="display"></select></div>
<div id="position-screen" class="position-screen" aria-label="屏幕位置预览"><div id="position-ball" class="position-ball"></div></div>
<div class="position-controls">
<div class="coordinates"><label for="position-x">X <input id="position-x" type="number" step="1"></label><label for="position-y">Y <input id="position-y" type="number" step="1"></label></div>
<div class="direction-pad" aria-label="方向键">
<button data-direction="up" title="上移">上</button>
<button data-direction="left" title="左移">左</button><button data-direction="center" title="回到屏幕中央">●</button><button data-direction="right" title="右移">右</button>
<button data-direction="down" title="下移">下</button>
</div>
</div>
<div class="preset-grid" aria-label="九宫格位置预设">
<button data-preset="top-left" title="左上"></button><button data-preset="top-center" title="顶部居中"></button><button data-preset="top-right" title="右上"></button>
<button data-preset="middle-left" title="左侧居中"></button><button data-preset="middle-center" title="屏幕中央"></button><button data-preset="middle-right" title="右侧居中"></button>
<button data-preset="bottom-left" title="左下"></button><button data-preset="bottom-center" title="底部居中"></button><button data-preset="bottom-right" title="右下"></button>
</div>
<div class="hint">方向键 1 像素,Shift + 方向键 10 像素</div>
</section>
</main>
<dialog id="login-notice">
<div class="modal-content">
<h2 id="login-notice-title">登录后端</h2>
<p>请先登录对应的网页平台。登录完成后即可关闭调试窗口。</p>
<div class="modal-actions">
<button id="login-notice-cancel" type="button">取消</button>
<button id="login-notice-continue" type="button" class="primary">打开登录页面</button>
</div>
</div>
</dialog>
<script src="settings.js"></script> <script src="settings.js"></script>
</body> </body>
</html> </html>
+71 -4
View File
@@ -11,6 +11,7 @@ interface SettingsViewState {
shortcut: string; shortcut: string;
autoStart: boolean; autoStart: boolean;
overlayPersistent: boolean; overlayPersistent: boolean;
diagnosticLogging: boolean;
overlayPosition: { x: number; y: number }; overlayPosition: { x: number; y: number };
displays: Array<{ id: string; label: string; workArea: { x: number; y: number; width: number; height: number } }>; displays: Array<{ id: string; label: string; workArea: { x: number; y: number; width: number; height: number } }>;
version: string; version: string;
@@ -26,11 +27,19 @@ const y = document.querySelector<HTMLInputElement>('#position-y')!;
const shortcut = document.querySelector<HTMLButtonElement>('#shortcut')!; const shortcut = document.querySelector<HTMLButtonElement>('#shortcut')!;
const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!; const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!;
const overlayPersistent = document.querySelector<HTMLInputElement>('#overlay-persistent')!; const overlayPersistent = document.querySelector<HTMLInputElement>('#overlay-persistent')!;
const diagnosticLogging = document.querySelector<HTMLInputElement>('#diagnostic-logging')!;
const version = document.querySelector<HTMLElement>('#version')!; const version = document.querySelector<HTMLElement>('#version')!;
const repository = document.querySelector<HTMLButtonElement>('#repository')!; const repository = document.querySelector<HTMLButtonElement>('#repository')!;
const message = document.querySelector<HTMLElement>('#message')!; const message = document.querySelector<HTMLElement>('#message')!;
const settingsView = document.querySelector<HTMLElement>('body > main')!;
const positionView = document.querySelector<HTMLElement>('#position-view')!;
const positionScreen = document.querySelector<HTMLElement>('#position-screen')!;
const positionBall = document.querySelector<HTMLElement>('#position-ball')!;
const loginNotice = document.querySelector<HTMLDialogElement>('#login-notice')!;
const loginNoticeTitle = document.querySelector<HTMLElement>('#login-notice-title')!;
let state: SettingsViewState; let state: SettingsViewState;
let capturingShortcut = false; let capturingShortcut = false;
let positionMode = false;
function statusText(status: BackendRuntimeStatus): string { function statusText(status: BackendRuntimeStatus): string {
if (status.ready) return '已就绪'; if (status.ready) return '已就绪';
@@ -59,14 +68,25 @@ function render(next: SettingsViewState): void {
? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? ''); ? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? '');
x.value = String(next.overlayPosition.x); x.value = String(next.overlayPosition.x);
y.value = String(next.overlayPosition.y); y.value = String(next.overlayPosition.y);
renderPositionPreview(next);
shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut); shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut);
autoStart.checked = next.autoStart; autoStart.checked = next.autoStart;
overlayPersistent.checked = next.overlayPersistent; overlayPersistent.checked = next.overlayPersistent;
diagnosticLogging.checked = next.diagnosticLogging;
version.textContent = next.version; version.textContent = next.version;
repository.textContent = next.repositoryUrl ?? '尚未配置'; repository.textContent = next.repositoryUrl ?? '尚未配置';
repository.disabled = !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 { function displayShortcut(value: string): string {
return value.replaceAll('Control', 'Ctrl').replaceAll('Super', 'Meta').replace(/\+space$/i, '+Space'); return value.replaceAll('Control', 'Ctrl').replaceAll('Super', 'Meta').replace(/\+space$/i, '+Space');
} }
@@ -101,13 +121,50 @@ async function applyPosition(): Promise<void> {
backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value))); backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value)));
document.querySelector('#open-debug')!.addEventListener('click', () => void window.meloSettings.openDebug()); 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<HTMLButtonElement>('[data-preset]').forEach((button) => { document.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
button.addEventListener('click', () => void apply( button.addEventListener('click', () => void apply(
() => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存' () => 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<void> {
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<HTMLButtonElement>('[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) => { document.addEventListener('keydown', (event) => {
if (capturingShortcut) { if (capturingShortcut) {
@@ -123,8 +180,8 @@ document.addEventListener('keydown', (event) => {
void apply(() => window.meloSettings.setShortcut(value), '快捷键已保存'); void apply(() => window.meloSettings.setShortcut(value), '快捷键已保存');
return; return;
} }
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return; if (!positionMode || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
if ((event.target as HTMLElement).matches('select, button, input[type="checkbox"]')) return; if ((event.target as HTMLElement).matches('select, input')) return;
event.preventDefault(); event.preventDefault();
const step = event.shiftKey ? 10 : 1; const step = event.shiftKey ? 10 : 1;
if (event.key === 'ArrowLeft') x.value = String(Number(x.value) - step); 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( overlayPersistent.addEventListener('change', () => void apply(
() => window.meloSettings.setOverlayPersistent(overlayPersistent.checked) () => window.meloSettings.setOverlayPersistent(overlayPersistent.checked)
)); ));
diagnosticLogging.addEventListener('change', () => void apply(
() => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked)
));
repository.addEventListener('click', () => void window.meloSettings.openRepository()); repository.addEventListener('click', () => void window.meloSettings.openRepository());
window.meloSettings.onState(render); 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; }); void window.meloSettings.getState().then(render).catch((error: Error) => { message.textContent = error.message; });
+2
View File
@@ -12,6 +12,7 @@ export interface AppSettings {
shortcut: string; shortcut: string;
autoStart: boolean; autoStart: boolean;
overlayPersistent: boolean; overlayPersistent: boolean;
diagnosticLogging: boolean;
overlayPosition: Point | null; overlayPosition: Point | null;
loginNoticeShown: Record<string, boolean>; loginNoticeShown: Record<string, boolean>;
} }
@@ -43,6 +44,7 @@ export interface SettingsViewState {
shortcut: string; shortcut: string;
autoStart: boolean; autoStart: boolean;
overlayPersistent: boolean; overlayPersistent: boolean;
diagnosticLogging: boolean;
overlayPosition: Point; overlayPosition: Point;
displays: DisplayInfo[]; displays: DisplayInfo[];
version: string; version: string;
+16
View File
@@ -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');
});
+12
View File
@@ -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 });
});
+15
View File
@@ -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);
});
+18
View File
@@ -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
});
});
+29
View File
@@ -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);
});