mirror of
https://github.com/Melorise/TamaASR.git
synced 2026-11-07 14:39:32 +00:00
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:
@@ -5,3 +5,5 @@ dist-test/
|
||||
release/
|
||||
packaging/out/
|
||||
chatgpt_amd64.deb
|
||||
backend.js
|
||||
settings.html
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 项目范围
|
||||
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.0`。
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.7`。
|
||||
|
||||
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 项目范围
|
||||
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.0`。
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.7`。
|
||||
|
||||
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
元宝仍需要使用具备语音输入权限的真实账号完成发布前端到端验证。不要把页面能加载等同于语音识别已经验证。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
#include "protocolsession.h"
|
||||
#include "preeditfinalizer.h"
|
||||
#include "keyeventclassifier.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<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
@@ -28,12 +28,16 @@
|
||||
#include <fcitx/text.h>
|
||||
|
||||
#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<fcitx::InputContextEvent &>(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<fcitx::InputContextEvent &>(event).inputContext());
|
||||
});
|
||||
destroyedWatcher_ = instance_->watchEvent(
|
||||
@@ -72,8 +80,9 @@ public:
|
||||
auto *context = static_cast<fcitx::InputContextEvent &>(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<bool>();
|
||||
}
|
||||
if (!message.contains("shortcut") || !message["shortcut"].is_string() ||
|
||||
awaitingStart_ || session_.active()) return;
|
||||
fcitx::Key candidate(message["shortcut"].get<std::string>());
|
||||
@@ -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<std::string>());
|
||||
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<std::int64_t>();
|
||||
if (!session_.accept(message.value("sessionId", std::string()), incomingRevision)) return;
|
||||
currentText_ = message["text"].get<std::string>();
|
||||
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;
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meloasr",
|
||||
"productName": "MeloASR",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"author": "MeloASR contributors",
|
||||
"description": "基于网页语音识别后端和 Fcitx5 的 Linux 实时语音输入工具",
|
||||
|
||||
+3
-3
@@ -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_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
|
||||
产物位于 `packaging/out/meloasr_0.1.7_<arch>.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 构建环境逐一构建、安装、卸载;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 发布源。
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /opt/meloasr/meloasr "$@"
|
||||
|
||||
exec /opt/meloasr/meloasr --ozone-platform=x11 "$@"
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<p>MeloASR connects supported web speech-recognition backends to the active Fcitx5 input context.</p>
|
||||
<p xml:lang="zh-CN">MeloASR 将支持的网页语音识别后端连接到当前 Fcitx5 输入上下文。</p>
|
||||
</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>
|
||||
<provides>
|
||||
<binary>meloasr</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="0.1.0" date="2026-08-23"/>
|
||||
<release version="0.1.7" date="2026-08-23"/>
|
||||
</releases>
|
||||
</component>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/" \;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
+6
-18
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '页面尚未进入可语音输入状态'
|
||||
};
|
||||
}
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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());
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+27
-6
@@ -29,12 +29,13 @@ async function bootstrap(): Promise<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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 {
|
||||
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),
|
||||
|
||||
+11
-13
@@ -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<TrayState, string> = {
|
||||
loading: '#e6a23c',
|
||||
ready: '#22b573',
|
||||
'login-required': '#ef6c57',
|
||||
error: '#d9465f'
|
||||
const ICONS: Record<TrayState, string> = {
|
||||
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="14" fill="${color}"/>
|
||||
<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 });
|
||||
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 }
|
||||
|
||||
+98
-6
@@ -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<typeof setTimeout> | undefined;
|
||||
let cancelTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let statusTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let transcriptRevision = 0;
|
||||
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 {
|
||||
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<BackendTranscriptPayload, 'backend'> = { 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) => {
|
||||
|
||||
@@ -4,18 +4,25 @@ import type { Point, SettingsViewState } from '../shared/contracts';
|
||||
contextBridge.exposeInMainWorld('meloSettings', {
|
||||
getState: (): Promise<SettingsViewState> => ipcRenderer.invoke('settings:get-state'),
|
||||
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> =>
|
||||
ipcRenderer.invoke('settings:set-position', position, displayId),
|
||||
setPreset: (preset: string, displayId: string): Promise<SettingsViewState> =>
|
||||
ipcRenderer.invoke('settings:set-preset', preset, displayId),
|
||||
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),
|
||||
setAutoStart: (enabled: boolean): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-auto-start', enabled),
|
||||
setOverlayPersistent: (enabled: boolean): Promise<SettingsViewState> =>
|
||||
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'),
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shouldClickMicrophoneOnCancel(active: boolean, stopping: boolean): boolean {
|
||||
return active && !stopping;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+5
-1
@@ -5,15 +5,19 @@ declare global {
|
||||
meloSettings: {
|
||||
getState(): Promise<SettingsViewState>;
|
||||
setBackend(id: string): Promise<SettingsViewState>;
|
||||
openDebug(): Promise<void>;
|
||||
openDebug(confirmed?: boolean): Promise<void>;
|
||||
setPosition(position: Point, displayId: string): Promise<SettingsViewState>;
|
||||
setPreset(preset: string, displayId: string): Promise<SettingsViewState>;
|
||||
previewOverlay(): Promise<void>;
|
||||
beginPositioning(): Promise<void>;
|
||||
endPositioning(): Promise<void>;
|
||||
setShortcut(shortcut: string): Promise<SettingsViewState>;
|
||||
setAutoStart(enabled: boolean): Promise<SettingsViewState>;
|
||||
setOverlayPersistent(enabled: boolean): Promise<SettingsViewState>;
|
||||
setDiagnosticLogging(enabled: boolean): Promise<SettingsViewState>;
|
||||
openRepository(): Promise<void>;
|
||||
onState(listener: (state: SettingsViewState) => void): void;
|
||||
onLoginNotice(listener: (backendLabel: string) => void): void;
|
||||
};
|
||||
meloOverlay: {
|
||||
toggleRecording(): void;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+33
-12
@@ -19,24 +19,14 @@
|
||||
<section>
|
||||
<h2>悬浮球</h2>
|
||||
<label class="switch"><input id="overlay-persistent" type="checkbox"><span>始终显示悬浮球</span></label>
|
||||
<div class="row"><label for="display">屏幕</label><select id="display"></select></div>
|
||||
<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>
|
||||
<button id="open-position">调整悬浮球位置</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>常规</h2>
|
||||
<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="diagnostic-logging" type="checkbox"><span>记录诊断日志</span></label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -46,6 +36,37 @@
|
||||
</section>
|
||||
<div id="message" role="status"></div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<HTMLInputElement>('#position-y')!;
|
||||
const shortcut = document.querySelector<HTMLButtonElement>('#shortcut')!;
|
||||
const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!;
|
||||
const overlayPersistent = document.querySelector<HTMLInputElement>('#overlay-persistent')!;
|
||||
const diagnosticLogging = document.querySelector<HTMLInputElement>('#diagnostic-logging')!;
|
||||
const version = document.querySelector<HTMLElement>('#version')!;
|
||||
const repository = document.querySelector<HTMLButtonElement>('#repository')!;
|
||||
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 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<void> {
|
||||
|
||||
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<HTMLButtonElement>('[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<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) => {
|
||||
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; });
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface AppSettings {
|
||||
shortcut: string;
|
||||
autoStart: boolean;
|
||||
overlayPersistent: boolean;
|
||||
diagnosticLogging: boolean;
|
||||
overlayPosition: Point | null;
|
||||
loginNoticeShown: Record<string, boolean>;
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export interface SettingsViewState {
|
||||
shortcut: string;
|
||||
autoStart: boolean;
|
||||
overlayPersistent: boolean;
|
||||
diagnosticLogging: boolean;
|
||||
overlayPosition: Point;
|
||||
displays: DisplayInfo[];
|
||||
version: string;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user