Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f7bc99e9 | ||
|
|
22784a38e7 | ||
|
|
443a462421 | ||
|
|
376473f92e | ||
|
|
368bc643a4 | ||
|
|
2100ebab6b | ||
|
|
39ddfd8eaa | ||
|
|
edd352e3d4 | ||
|
|
ed1bf16e24 | ||
|
|
d2c53823cd | ||
|
|
26d54fb8e9 | ||
|
|
2233cc6cae | ||
|
|
43faa05c30 | ||
|
|
16ddf53186 | ||
|
|
cc4d747a3a | ||
|
|
88a91892d9 | ||
|
|
e6f67be337 | ||
|
|
cbe34e91ef | ||
|
|
8f8244af05 | ||
|
|
6ea57c6375 | ||
|
|
adfa4df236 | ||
|
|
cd3fc9a7d7 | ||
|
|
6c3013b0ae | ||
|
|
7687350ca4 | ||
|
|
e3759efa48 | ||
|
|
4e58ca6d1d | ||
|
|
599cb2a120 |
@@ -87,10 +87,15 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.update-flake-lock.outputs.commit_sha }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.21.0
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache: pnpm
|
||||
|
||||
- name: 安装系统构建依赖
|
||||
run: |
|
||||
@@ -101,13 +106,13 @@ jobs:
|
||||
|
||||
- name: 构建 deb 与 rpm
|
||||
env:
|
||||
MELOASR_VERSION: ${{ needs.update-flake-lock.outputs.version }}
|
||||
MELOASR_DEB_ARCH: ${{ matrix.deb_arch }}
|
||||
MELOASR_ELECTRON_APP_DIR: ${{ matrix.electron_app_dir }}
|
||||
TAMA_ASR_VERSION: ${{ needs.update-flake-lock.outputs.version }}
|
||||
TAMA_ASR_DEB_ARCH: ${{ matrix.deb_arch }}
|
||||
TAMA_ASR_ELECTRON_APP_DIR: ${{ matrix.electron_app_dir }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run package:deb
|
||||
npm run package:rpm
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run package:deb
|
||||
pnpm run package:rpm
|
||||
mkdir -p release-artifacts
|
||||
cp packaging/out/*.deb packaging/out/*.rpm release-artifacts/
|
||||
|
||||
@@ -160,17 +165,17 @@ jobs:
|
||||
env:
|
||||
CACHIX_CACHE_NAME: ${{ vars.CACHIX_CACHE_NAME }}
|
||||
run: |
|
||||
nix build ".#packages.${{ matrix.system }}.meloasr"
|
||||
nix build ".#packages.${{ matrix.system }}.tama-asr"
|
||||
store_path="$(readlink -f result)"
|
||||
cachix push "$CACHIX_CACHE_NAME" "$store_path"
|
||||
cachix pin --keep-revisions 1 "$CACHIX_CACHE_NAME" \
|
||||
"meloasr-${{ matrix.system }}" "$store_path"
|
||||
printf '%s\n' "$store_path" > "meloasr-${{ matrix.system }}.txt"
|
||||
"tama-asr-${{ matrix.system }}" "$store_path"
|
||||
printf '%s\n' "$store_path" > "tama-asr-${{ matrix.system }}.txt"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nix-build-${{ matrix.system }}
|
||||
path: meloasr-${{ matrix.system }}.txt
|
||||
path: tama-asr-${{ matrix.system }}.txt
|
||||
|
||||
publish-release:
|
||||
name: 创建 GitHub Release
|
||||
@@ -204,14 +209,17 @@ jobs:
|
||||
path: release
|
||||
merge-multiple: true
|
||||
|
||||
- name: 附带构建使用的 flake.lock
|
||||
run: cp flake.lock release/flake.lock
|
||||
- name: 附带发行元数据
|
||||
run: |
|
||||
cp flake.lock release/flake.lock
|
||||
cp packaging/arch/PKGBUILD release/PKGBUILD
|
||||
cp packaging/arch/PKGBUILD-from-source release/PKGBUILD-from-source
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.update-flake-lock.outputs.version }}
|
||||
target_commitish: ${{ needs.update-flake-lock.outputs.commit_sha }}
|
||||
name: MeloASR ${{ needs.update-flake-lock.outputs.version }}
|
||||
name: TamaASR ${{ needs.update-flake-lock.outputs.version }}
|
||||
body_path: release-notes.md
|
||||
files: release/*
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
name: Update Nix Cache
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 17 * * 2"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: update-nix-cache-master
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update-flake-lock:
|
||||
name: 更新 Nix 锁文件
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
commit_sha: ${{ steps.commit.outputs.commit_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: DeterminateSystems/nix-installer-action@v20
|
||||
|
||||
- name: 更新 flake.lock
|
||||
run: nix flake update
|
||||
|
||||
- id: commit
|
||||
name: 提交锁文件更新
|
||||
env:
|
||||
GIT_AUTHOR_NAME: github-actions[bot]
|
||||
GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
GIT_COMMITTER_NAME: github-actions[bot]
|
||||
GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
run: |
|
||||
if [[ -n "$(git status --porcelain -- flake.lock)" ]]; then
|
||||
git add flake.lock
|
||||
git commit -m "build: 更新 flake.lock"
|
||||
git push origin HEAD:master
|
||||
fi
|
||||
echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-nix:
|
||||
name: Nix ${{ matrix.system }}
|
||||
needs: update-flake-lock
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- system: x86_64-linux
|
||||
runner: ubuntu-24.04
|
||||
- system: aarch64-linux
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.update-flake-lock.outputs.commit_sha }}
|
||||
|
||||
- uses: DeterminateSystems/nix-installer-action@v20
|
||||
|
||||
- uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: ${{ vars.CACHIX_CACHE_NAME }}
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: 校验并配置 Cachix
|
||||
env:
|
||||
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
CACHIX_CACHE_NAME: ${{ vars.CACHIX_CACHE_NAME }}
|
||||
CACHIX_PUBLIC_KEY: ${{ vars.CACHIX_PUBLIC_KEY }}
|
||||
run: |
|
||||
test -n "$CACHIX_AUTH_TOKEN"
|
||||
test -n "$CACHIX_CACHE_NAME"
|
||||
test -n "$CACHIX_PUBLIC_KEY"
|
||||
mkdir -p "$HOME/.config/nix"
|
||||
{
|
||||
echo "extra-substituters = https://$CACHIX_CACHE_NAME.cachix.org"
|
||||
echo "extra-trusted-public-keys = $CACHIX_PUBLIC_KEY"
|
||||
} >> "$HOME/.config/nix/nix.conf"
|
||||
|
||||
- name: 构建、上传并更新最新 pin
|
||||
env:
|
||||
CACHIX_CACHE_NAME: ${{ vars.CACHIX_CACHE_NAME }}
|
||||
run: |
|
||||
nix build ".#packages.${{ matrix.system }}.tama-asr"
|
||||
store_path="$(readlink -f result)"
|
||||
cachix push "$CACHIX_CACHE_NAME" "$store_path"
|
||||
cachix pin --keep-revisions 1 "$CACHIX_CACHE_NAME" \
|
||||
"tama-asr-${{ matrix.system }}" "$store_path"
|
||||
@@ -1,13 +1,15 @@
|
||||
# MeloASR 维护约定
|
||||
# TamaASR 维护约定
|
||||
|
||||
## 项目范围
|
||||
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.10`。
|
||||
TamaASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `TamaASR`,分发包名为 `tama-asr`,当前版本为 `1.0.0`。
|
||||
|
||||
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
|
||||
|
||||
除非用户另有要求,文档、界面文字和代码注释使用简体中文。
|
||||
|
||||
`AGENTS.md` 与 `CLAUDE.md` 必须始终逐字一致。修改其中任意一份时必须把相同内容同步到另一份,并在提交前使用字节比较确认一致。
|
||||
|
||||
## 核心不变量
|
||||
|
||||
- 网页编辑器中的当前完整内容是唯一可信识别结果。
|
||||
@@ -42,7 +44,7 @@ MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `M
|
||||
|
||||
## Fcitx5 与本地协议
|
||||
|
||||
- Socket:`$XDG_RUNTIME_DIR/meloasr/fcitx5.sock`;目录 `0700`,Socket `0600`;
|
||||
- Socket:`$XDG_RUNTIME_DIR/tama-asr/fcitx5.sock`;目录 `0700`,Socket `0600`;
|
||||
- JSONL 协议版本:2;
|
||||
- `sessionId` 隔离轮次,`revision` 单调递增;
|
||||
- `text` 永远是当前完整文本;
|
||||
@@ -73,12 +75,20 @@ MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `M
|
||||
核心命令:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
npm run build:fcitx5
|
||||
pnpm test
|
||||
pnpm run build
|
||||
pnpm run build:fcitx5
|
||||
ctest --test-dir linux/fcitx5/build --output-on-failure
|
||||
```
|
||||
|
||||
验证必须与实际改动范围对应,禁止为了展示“完整”而机械扩大测试范围:
|
||||
|
||||
- TypeScript、preload 或 renderer 改动运行 `pnpm test` 和 `pnpm run build`;
|
||||
- 只有修改 Fcitx5 C++、CMake 构建逻辑或本地协议实现时,才运行 `pnpm run build:fcitx5` 和 `ctest`;仅同步发行版本号不要求重测未改动的 Fcitx5 逻辑;
|
||||
- FHS 发行版是首要维护目标,FHS 构建或打包问题必须在对应 Debian、Ubuntu、RPM 或 Arch 环境处理,不得默认用 Nix 环境替代;Nix 只用于验证 Nix 打包路径;
|
||||
- 项目依赖未安装时,按 `pnpm-lock.yaml` 运行 `pnpm install --frozen-lockfile`,不能以 `tsc` 或其它工具缺失为由跳过正常验证;
|
||||
- 不得声称添加了未实际添加的测试,也不得把无关环境中的成功构建计入目标平台验证结果。
|
||||
|
||||
最低动态回归:连续三轮;文本缩短、改词和补标点;finish 仅一次;cancel/reset/disconnect;ready gate;登录探测;后端切换;多屏位置和键盘微调;托盘退出/重启;自启动;GTK、Qt、Chromium 和终端;X11 与 Wayland。
|
||||
|
||||
不要声称没有真实验证的发行版包或元宝端到端识别已经通过。
|
||||
@@ -87,6 +97,26 @@ ctest --test-dir linux/fcitx5/build --output-on-failure
|
||||
|
||||
deb、rpm、Arch/pacman 和 Nix 都必须包含 Electron 应用、desktop/icon/autostart 文件和 Fcitx5 addon。桌面 ID、包名、Socket、addon 名称和版本保持一致。仓库地址和许可证确定前,不得把占位值作为正式发行元数据。
|
||||
|
||||
版本发布时同步 `package.json`、`pnpm-lock.yaml`、CHANGELOG、AppStream、Fcitx5 addon 版本和各发行版打包元数据,并运行 `scripts/release-metadata.mjs <version>` 校验发布说明。
|
||||
|
||||
`v*` Git 标签由远程 Release 工作流产生。除非用户明确要求,本地禁止创建、移动或删除发布标签,也禁止推送提交或标签。“更新版本”或“更新发版”本身不等于授权本地打标签。
|
||||
|
||||
## 工程判断与性能修改
|
||||
|
||||
- 先区分稳定对象与对象内部的可变内容。一次语音会话中编辑器根元素视为稳定对象,可以缓存;识别文本所在的子树内容持续变化,必须重新序列化完整结果。
|
||||
- 不得用没有实际证据的极端场景否定正常工程假设,例如假设同一录音会话中的编辑器根元素会被持续重建。若稳定对象意外失效,应采用明确失败策略,不能为猜测场景增加常态热路径开销。
|
||||
- 性能结论必须区分静态代码分析与真实测量。没有 profiler、计时或内存采样时,不得给出伪精确的性能收益。
|
||||
- 优化热路径时先消除重复 DOM 查询、重复序列化、重复 IPC 和无意义轮询,不得破坏网页常驻、实时启动录音和完整文本同步这些产品前提。
|
||||
|
||||
## 工作边界与交付检查
|
||||
|
||||
- 开始操作前确认实际 Git 仓库根目录,不能仅依赖外层工作区路径。
|
||||
- 用户要求实现修改时,应完成代码、对应验证、版本与 CHANGELOG 更新等明确要求;遇到缺失依赖要解决或准确报告真实阻塞,不能自行缩减交付范围。
|
||||
- 不得为了反驳用户而引入没有证据的假设。用户指出实现与事实矛盾时,应先核对代码链路并修正判断。
|
||||
- 只修改任务相关文件,不运行与改动无关的构建,不擅自扩大发布、标签、推送或外部操作范围。
|
||||
- 提交前检查 `git diff --check`、发行元数据校验和对应测试;提交后检查 `git status --short`、提交内容和工作树是否干净。
|
||||
- 只要修改了仓库文件,就必须在本轮创建对应 Git commit,不得留下未提交改动;如果不准备或不能提交,就不得修改文件。Tag 和 push 仍需分别获得用户明确授权,不能从修改或 commit 推定。
|
||||
|
||||
## 提交约定
|
||||
|
||||
按功能粒度提交,只包含相关文件。提交前运行对应验证。AI 辅助提交尾部统一添加:
|
||||
|
||||
@@ -2,6 +2,70 @@
|
||||
|
||||
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的格式。
|
||||
|
||||
## [1.0.1] - 2026-09-01
|
||||
|
||||
### 变更
|
||||
|
||||
- 重新设计设置界面,统一 TamaASR 品牌色、控件层级、服务状态、快捷键、开关与即时保存反馈。
|
||||
- 改进悬浮球位置调整页,增加桌面预览、九宫格选中状态、精确坐标与方向微调控件。
|
||||
- 补充键盘焦点、辅助文本、减少动态效果偏好和窄窗口布局支持。
|
||||
|
||||
## [1.0.0] - 2026-09-01
|
||||
|
||||
### 变更
|
||||
|
||||
- 项目更名为 TamaASR,分发包名改为 `tama-asr`,并迁移应用标识、Fcitx5 addon、本地 Socket、持久会话、桌面入口及安装路径。
|
||||
- 将仓库地址更新为 <https://github.com/Melorise/TamaASR>,并为 deb、RPM 和 Arch 包声明替换旧包的关系。
|
||||
|
||||
## [0.1.17] - 2026-09-01
|
||||
|
||||
### 变更
|
||||
|
||||
- 更新应用图标,并将托盘图标统一为“已就绪”和“未就绪”两种状态。
|
||||
|
||||
## [0.1.16] - 2026-08-26
|
||||
|
||||
### 修复
|
||||
|
||||
- 项目依赖管理、构建脚本、Arch 打包和 Release 工作流统一迁移到 pnpm,避免 npm 将第三方镜像地址识别为禁用的 remote 包。
|
||||
- 使用不包含 registry 下载地址的 `pnpm-lock.yaml` 替代 `package-lock.json`,使锁文件不再固化生成环境的 npm 镜像。
|
||||
|
||||
## [0.1.15] - 2026-08-26
|
||||
|
||||
### 修复
|
||||
|
||||
- Arch PKGBUILD 改为直接下载 GitHub Release 标签对应的源码归档,不再要求发布者或用户手动放置源码包。
|
||||
- 修正 GitHub 标签归档的解压目录处理,使 `makepkg` 可以直接完成构建、测试和打包。
|
||||
- 远程 Release 现在附带当前版本的 `PKGBUILD`,Arch 用户无需再从源码树中单独提取打包文件。
|
||||
|
||||
## [0.1.14] - 2026-08-26
|
||||
|
||||
### 性能
|
||||
|
||||
- 语音会话开始时缓存网页编辑器根元素,后续文本更新直接序列化该元素,避免每次更新重复查询 DOM、计算样式和读取布局。
|
||||
- 复用会话启动时的初始文本序列化结果,减少一次重复的完整编辑器遍历。
|
||||
|
||||
## [0.1.13] - 2026-08-24
|
||||
|
||||
### 变更
|
||||
|
||||
- 设置页改为按需创建,关闭窗口后立即销毁其 renderer 以降低常驻内存。
|
||||
- 缩小设置页默认与最小窗口尺寸,使其匹配当前功能密度。
|
||||
|
||||
## [0.1.12] - 2026-08-23
|
||||
|
||||
### 变更
|
||||
|
||||
- 全平台启动时强制禁用 Electron GPU 硬件加速,降低后台网页 renderer 的无效显存与内存占用。
|
||||
|
||||
## [0.1.11] - 2026-08-23
|
||||
|
||||
### 变更
|
||||
|
||||
- 切换后端时销毁非当前后端的网页窗口,保留登录会话但不再保活其 renderer。
|
||||
- 移除悬浮球点击录音和常驻显示功能,悬浮球仅用于显示语音会话状态。
|
||||
- 缩短正常完成后的绿色状态显示时间。
|
||||
|
||||
## [0.1.10] - 2026-08-23
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# MeloASR 维护约定
|
||||
# TamaASR 维护约定
|
||||
|
||||
## 项目范围
|
||||
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.10`。
|
||||
TamaASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `TamaASR`,分发包名为 `tama-asr`,当前版本为 `1.0.0`。
|
||||
|
||||
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
|
||||
|
||||
除非用户另有要求,文档、界面文字和代码注释使用简体中文。
|
||||
|
||||
`AGENTS.md` 与 `CLAUDE.md` 必须始终逐字一致。修改其中任意一份时必须把相同内容同步到另一份,并在提交前使用字节比较确认一致。
|
||||
|
||||
## 核心不变量
|
||||
|
||||
- 网页编辑器中的当前完整内容是唯一可信识别结果。
|
||||
@@ -42,7 +44,7 @@ MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `M
|
||||
|
||||
## Fcitx5 与本地协议
|
||||
|
||||
- Socket:`$XDG_RUNTIME_DIR/meloasr/fcitx5.sock`;目录 `0700`,Socket `0600`;
|
||||
- Socket:`$XDG_RUNTIME_DIR/tama-asr/fcitx5.sock`;目录 `0700`,Socket `0600`;
|
||||
- JSONL 协议版本:2;
|
||||
- `sessionId` 隔离轮次,`revision` 单调递增;
|
||||
- `text` 永远是当前完整文本;
|
||||
@@ -73,12 +75,20 @@ MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `M
|
||||
核心命令:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
npm run build:fcitx5
|
||||
pnpm test
|
||||
pnpm run build
|
||||
pnpm run build:fcitx5
|
||||
ctest --test-dir linux/fcitx5/build --output-on-failure
|
||||
```
|
||||
|
||||
验证必须与实际改动范围对应,禁止为了展示“完整”而机械扩大测试范围:
|
||||
|
||||
- TypeScript、preload 或 renderer 改动运行 `pnpm test` 和 `pnpm run build`;
|
||||
- 只有修改 Fcitx5 C++、CMake 构建逻辑或本地协议实现时,才运行 `pnpm run build:fcitx5` 和 `ctest`;仅同步发行版本号不要求重测未改动的 Fcitx5 逻辑;
|
||||
- FHS 发行版是首要维护目标,FHS 构建或打包问题必须在对应 Debian、Ubuntu、RPM 或 Arch 环境处理,不得默认用 Nix 环境替代;Nix 只用于验证 Nix 打包路径;
|
||||
- 项目依赖未安装时,按 `pnpm-lock.yaml` 运行 `pnpm install --frozen-lockfile`,不能以 `tsc` 或其它工具缺失为由跳过正常验证;
|
||||
- 不得声称添加了未实际添加的测试,也不得把无关环境中的成功构建计入目标平台验证结果。
|
||||
|
||||
最低动态回归:连续三轮;文本缩短、改词和补标点;finish 仅一次;cancel/reset/disconnect;ready gate;登录探测;后端切换;多屏位置和键盘微调;托盘退出/重启;自启动;GTK、Qt、Chromium 和终端;X11 与 Wayland。
|
||||
|
||||
不要声称没有真实验证的发行版包或元宝端到端识别已经通过。
|
||||
@@ -87,6 +97,26 @@ ctest --test-dir linux/fcitx5/build --output-on-failure
|
||||
|
||||
deb、rpm、Arch/pacman 和 Nix 都必须包含 Electron 应用、desktop/icon/autostart 文件和 Fcitx5 addon。桌面 ID、包名、Socket、addon 名称和版本保持一致。仓库地址和许可证确定前,不得把占位值作为正式发行元数据。
|
||||
|
||||
版本发布时同步 `package.json`、`pnpm-lock.yaml`、CHANGELOG、AppStream、Fcitx5 addon 版本和各发行版打包元数据,并运行 `scripts/release-metadata.mjs <version>` 校验发布说明。
|
||||
|
||||
`v*` Git 标签由远程 Release 工作流产生。除非用户明确要求,本地禁止创建、移动或删除发布标签,也禁止推送提交或标签。“更新版本”或“更新发版”本身不等于授权本地打标签。
|
||||
|
||||
## 工程判断与性能修改
|
||||
|
||||
- 先区分稳定对象与对象内部的可变内容。一次语音会话中编辑器根元素视为稳定对象,可以缓存;识别文本所在的子树内容持续变化,必须重新序列化完整结果。
|
||||
- 不得用没有实际证据的极端场景否定正常工程假设,例如假设同一录音会话中的编辑器根元素会被持续重建。若稳定对象意外失效,应采用明确失败策略,不能为猜测场景增加常态热路径开销。
|
||||
- 性能结论必须区分静态代码分析与真实测量。没有 profiler、计时或内存采样时,不得给出伪精确的性能收益。
|
||||
- 优化热路径时先消除重复 DOM 查询、重复序列化、重复 IPC 和无意义轮询,不得破坏网页常驻、实时启动录音和完整文本同步这些产品前提。
|
||||
|
||||
## 工作边界与交付检查
|
||||
|
||||
- 开始操作前确认实际 Git 仓库根目录,不能仅依赖外层工作区路径。
|
||||
- 用户要求实现修改时,应完成代码、对应验证、版本与 CHANGELOG 更新等明确要求;遇到缺失依赖要解决或准确报告真实阻塞,不能自行缩减交付范围。
|
||||
- 不得为了反驳用户而引入没有证据的假设。用户指出实现与事实矛盾时,应先核对代码链路并修正判断。
|
||||
- 只修改任务相关文件,不运行与改动无关的构建,不擅自扩大发布、标签、推送或外部操作范围。
|
||||
- 提交前检查 `git diff --check`、发行元数据校验和对应测试;提交后检查 `git status --short`、提交内容和工作树是否干净。
|
||||
- 只要修改了仓库文件,就必须在本轮创建对应 Git commit,不得留下未提交改动;如果不准备或不能提交,就不得修改文件。Tag 和 push 仍需分别获得用户明确授权,不能从修改或 commit 推定。
|
||||
|
||||
## 提交约定
|
||||
|
||||
按功能粒度提交,只包含相关文件。提交前运行对应验证。AI 辅助提交尾部统一添加:
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
# MeloASR
|
||||
# TamaASR
|
||||
|
||||
大模型的进步推动了自然语言处理的进化,语音输入正在成为主流。在其它桌面平台下,微信输入法,豆包输入法,千问输入法等支持语音输入甚至以此为卖点的新兴中文输入法正在成为主流。
|
||||
而在linux下,相关领域却长期空白,现有实现主要聚焦于以下几种类型:
|
||||
TamaASR 是面向 Linux/Fcitx5 的桌面语音输入工具。
|
||||
|
||||
和常规ASR软件不同,本软件不运行本地模型或要求用户提供ASR api进行调用,而是通过将语音输入转发到支持语音识别的AI网页对话助手,再将识别到的文字抓取并发送到fcitx5完成输入。
|
||||
|
||||
api调用——调用各大ai厂商的语音转文字api。显然,该方案最大的问题在于——要钱。
|
||||
本地模型——识别准确率低,技术落后,和其他平台的能实时校正的ai语音输入根本不是一个量级的东西,尤其是针对中文环境。
|
||||
您需要在本软件的内置浏览器里登录对应的AI助手平台,然后在外部按住快捷键即可进行输入。
|
||||
|
||||
我们注意到,很多大模型的网页对话窗口,都支持语音输入,而且完全免费——因为语音输入最后只是输入到文本框,并没有发送实际的对话请求,而大模型就算是付费也往往是根据对话请求来计费的。
|
||||
从官方渠道(Github Releases,星火商店等)获取到的本软件,不会获取您的任何个人信息、AI平台账户信息、对话记录或语音输入记录
|
||||
如果您是从其它渠道获取,且无法确认来源可信,请立即卸载并更换为官方来源的版本以防止个人信息泄露
|
||||
本项目遵循MPL-2.0许可证开源
|
||||
|
||||
因此,本项目使用了一种全新的思路:在后台运行一个electron应用,并在里面登录千问,元宝等网页版对话作为后端,实时抓取语音输入到输入框中的结果,并传递给fcitx5,从而让linux用户也用上高质量的语音输入。
|
||||
|
||||
简而言之,MeloASR 是面向 Linux/Fcitx5 的桌面语音输入工具。应用使用受支持网页平台自身的录音、流式识别、纠错、断句和标点能力,并把网页编辑器中的当前完整文本实时同步到活动的 Fcitx5 InputContext。
|
||||
|
||||
当前后端:
|
||||
当前支持的可作为输入源的后端:
|
||||
|
||||
- 千问
|
||||
- 元宝
|
||||
|
||||
### 本项目暂不考虑迁移到Tauri。我们充分理解这种需要后台常驻的系统性应用,在Tauri下开发似乎更加合适,事实上我们也确实进行了两次Tauri迁移。但元宝和千问web版对于webkit2gtk的表现实在是过于糟糕了(至少是在我的目标设备上),即使使用默认的gnome浏览器进行语音输入都会卡死,更不用说后续自动化的抓取了。还请各位理解,该项目是一个偏个人向的项目(毕竟使用的这种方式,大规模公开那就是商业侵权),而我的目标开发设备是一台非常老旧的设备,我没法说因为为了在他人的设备上获得更出色的性能而放弃对自己设备的适配。也许未来我们会在这些web端的兼容性得到改善,或者该目标设备被淘汰以后,才会考虑向Tauri迁移。
|
||||
|
||||
### 当前electron版的常驻占用内存在200mb左右。
|
||||
|
||||
### 项目暂不支持豆包后端。豆包的ASR输入是直接发送请求,而非像元宝和千问一样输出到输入框,这就需要拦截或自行重新实现请求,技术难度高且有风控风险。
|
||||
|
||||
### 同时该项目是中文输入主导的,也不考虑适配chatgpt等非中文优先的平台。
|
||||
|
||||
### 出于个人原因(多次尝试后效果不符合预期,个人设备不兼容等),本项目暂不考虑迁移到Tauri或QtWebEngine。
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -33,7 +23,7 @@ api调用——调用各大ai厂商的语音转文字api。显然,该方案最
|
||||
网页后端适配器
|
||||
→ Electron 后端窗口与登录会话
|
||||
→ 会话状态机
|
||||
→ $XDG_RUNTIME_DIR/meloasr/fcitx5.sock
|
||||
→ $XDG_RUNTIME_DIR/tama-asr/fcitx5.sock
|
||||
→ Fcitx5 常驻 addon
|
||||
→ 当前应用的 preedit / commit
|
||||
```
|
||||
@@ -54,12 +44,12 @@ api调用——调用各大ai厂商的语音转文字api。显然,该方案最
|
||||
依赖:Node.js 22+、CMake 3.16+、支持 C++20 的编译器、Fcitx5 Core/Utils 开发包、nlohmann-json。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm run build:fcitx5
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm run build:fcitx5
|
||||
./linux/fcitx5/install-user.sh
|
||||
fcitx5 -r
|
||||
npm start
|
||||
pnpm start
|
||||
```
|
||||
|
||||
应用默认由托盘常驻,设置页与后端调试窗口默认隐藏。首次打开某个后端的调试窗口时,应用会提示登录;登录状态保存在该后端自己的 persistent partition 中。
|
||||
@@ -80,18 +70,22 @@ npm start
|
||||
## Linux 分发
|
||||
|
||||
```bash
|
||||
npm run build:linux:dir
|
||||
npm run package:deb
|
||||
npm run package:rpm
|
||||
pnpm run build:linux:dir
|
||||
pnpm run package:deb
|
||||
pnpm run package:rpm
|
||||
```
|
||||
|
||||
Arch/pacman 配置位于 `packaging/arch/`,Nix 入口为 `flake.nix`。详细依赖、验证状态和发布前替换项见 `packaging/README.md`。
|
||||
|
||||
## Nix Cachix缓存源
|
||||
缓存源:https://meloasr.cachix.org
|
||||
公钥:meloasr.cachix.org-1:oMmpfj7D1pQTtJx6cHYupvPDJPFlkBB0JIpJYvkR5hc=
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
npm run build:fcitx5
|
||||
pnpm test
|
||||
pnpm run build
|
||||
pnpm run build:fcitx5
|
||||
ctest --test-dir linux/fcitx5/build --output-on-failure
|
||||
```
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1787360063,
|
||||
"narHash": "sha256-dt4WdcvsA8/RCe+VZZwqU0X+XMM3wBbGCWA0/sFWzGo=",
|
||||
"owner": "NixOS",
|
||||
"lastModified": 1788107362,
|
||||
"narHash": "sha256-pKxqcA71wmW6eMh3kFpW5NHIdbrgYOhtW2aOwqQ28GQ=",
|
||||
"owner": "Melorise",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",
|
||||
"rev": "e1c33116d631fcc7987a1a6b298622523ce1d66a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"owner": "Melorise",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
description = "MeloASR — web-backed voice input for Fcitx5";
|
||||
description = "TamaASR — web-backed voice input for Fcitx5";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
inputs.nixpkgs.url = "github:Melorise/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
@@ -12,13 +12,13 @@
|
||||
let pkgs = import nixpkgs { inherit system; };
|
||||
in {
|
||||
default = pkgs.callPackage ./packaging/nix/package.nix { };
|
||||
meloasr = self.packages.${system}.default;
|
||||
tama-asr = self.packages.${system}.default;
|
||||
});
|
||||
|
||||
apps = forAllSystems (system: {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/meloasr";
|
||||
program = "${self.packages.${system}.default}/bin/tama-asr";
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs
|
||||
pnpm_11
|
||||
cmake
|
||||
pkg-config
|
||||
fcitx5
|
||||
@@ -37,4 +38,3 @@
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(meloasr-fcitx5 VERSION 0.1.10 LANGUAGES CXX)
|
||||
project(tama-asr-fcitx5 VERSION 1.0.1 LANGUAGES CXX)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
include(CTest)
|
||||
@@ -8,16 +8,16 @@ find_package(Fcitx5Core 5.0 REQUIRED)
|
||||
find_package(Fcitx5Utils 5.0 REQUIRED)
|
||||
find_package(nlohmann_json 3.2 REQUIRED)
|
||||
|
||||
add_library(meloasr MODULE voiceinput.cpp)
|
||||
target_compile_features(meloasr PRIVATE cxx_std_20)
|
||||
target_compile_options(meloasr PRIVATE -Wall -Wextra -Wpedantic)
|
||||
target_link_libraries(meloasr PRIVATE Fcitx5::Core Fcitx5::Utils nlohmann_json::nlohmann_json)
|
||||
set_target_properties(meloasr PROPERTIES PREFIX "lib")
|
||||
add_library(tama-asr MODULE voiceinput.cpp)
|
||||
target_compile_features(tama-asr PRIVATE cxx_std_20)
|
||||
target_compile_options(tama-asr PRIVATE -Wall -Wextra -Wpedantic)
|
||||
target_link_libraries(tama-asr PRIVATE Fcitx5::Core Fcitx5::Utils nlohmann_json::nlohmann_json)
|
||||
set_target_properties(tama-asr PROPERTIES PREFIX "lib")
|
||||
|
||||
set(MELOASR_LIBRARY_PATH "${FCITX_INSTALL_ADDONDIR}/libmeloasr")
|
||||
configure_file(meloasr.conf.in meloasr.conf @ONLY)
|
||||
install(TARGETS meloasr LIBRARY DESTINATION "${FCITX_INSTALL_ADDONDIR}")
|
||||
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/meloasr.conf" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon")
|
||||
set(TAMA_ASR_LIBRARY_PATH "${FCITX_INSTALL_ADDONDIR}/libtama-asr")
|
||||
configure_file(tama-asr.conf.in tama-asr.conf @ONLY)
|
||||
install(TARGETS tama-asr LIBRARY DESTINATION "${FCITX_INSTALL_ADDONDIR}")
|
||||
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/tama-asr.conf" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon")
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_executable(protocolsession_test protocolsession_test.cpp)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# MeloASR Fcitx5 addon
|
||||
# TamaASR Fcitx5 addon
|
||||
|
||||
该 addon 作为 Fcitx5 常驻 Module 工作,不需要切换到独立输入法。
|
||||
|
||||
@@ -18,8 +18,8 @@ fcitx5 -r
|
||||
安装文件:
|
||||
|
||||
```text
|
||||
~/.local/lib/fcitx5/libmeloasr.so
|
||||
~/.local/share/fcitx5/addon/meloasr.conf
|
||||
~/.local/lib/fcitx5/libtama-asr.so
|
||||
~/.local/share/fcitx5/addon/tama-asr.conf
|
||||
```
|
||||
|
||||
本地协议使用 `$XDG_RUNTIME_DIR/meloasr/fcitx5.sock`,目录权限 `0700`,Socket 权限 `0600`。协议版本为 2,支持 `configure` 动态快捷键和 `activate` 悬浮球启动请求。
|
||||
本地协议使用 `$XDG_RUNTIME_DIR/tama-asr/fcitx5.sock`,目录权限 `0700`,Socket 权限 `0600`。协议版本为 2,支持 `configure` 动态快捷键和 `activate` 悬浮球启动请求。
|
||||
|
||||
@@ -17,4 +17,4 @@ rm -f -- \
|
||||
"${HOME}/.local/lib/fcitx5/libvoiceinput.so" \
|
||||
"${HOME}/.local/share/fcitx5/addon/voiceinput.conf"
|
||||
|
||||
echo "已安装 MeloASR Fcitx5 插件,请重启 Fcitx5。"
|
||||
echo "已安装 TamaASR Fcitx5 插件,请重启 Fcitx5。"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[Addon]
|
||||
Name=MeloASR
|
||||
Name[zh_CN]=MeloASR
|
||||
Comment=Connect MeloASR web backends to the active input context
|
||||
Comment[zh_CN]=将 MeloASR 网页后端连接到当前输入上下文
|
||||
Type=SharedLibrary
|
||||
Library=@MELOASR_LIBRARY_PATH@
|
||||
Category=Module
|
||||
Version=0.1.10
|
||||
OnDemand=False
|
||||
Configurable=False
|
||||
|
||||
[Addon/Dependencies]
|
||||
0=core:5.0.0
|
||||
@@ -0,0 +1,14 @@
|
||||
[Addon]
|
||||
Name=TamaASR
|
||||
Name[zh_CN]=TamaASR
|
||||
Comment=Connect TamaASR web backends to the active input context
|
||||
Comment[zh_CN]=将 TamaASR 网页后端连接到当前输入上下文
|
||||
Type=SharedLibrary
|
||||
Library=@TAMA_ASR_LIBRARY_PATH@
|
||||
Category=Module
|
||||
Version=1.0.1
|
||||
OnDemand=False
|
||||
Configurable=False
|
||||
|
||||
[Addon/Dependencies]
|
||||
0=core:5.0.0
|
||||
@@ -35,7 +35,7 @@ namespace {
|
||||
constexpr int kProtocolVersion = 2;
|
||||
constexpr std::size_t kMaxBufferBytes = 1024 * 1024;
|
||||
|
||||
#define MELOASR_DIAGNOSTIC_INFO if (!diagnosticLogging_) {} else FCITX_INFO()
|
||||
#define TAMA_ASR_DIAGNOSTIC_INFO if (!diagnosticLogging_) {} else FCITX_INFO()
|
||||
|
||||
class VoiceInputAddon final : public fcitx::AddonInstance {
|
||||
public:
|
||||
@@ -53,7 +53,7 @@ public:
|
||||
fcitx::EventType::InputContextFocusOut,
|
||||
fcitx::EventWatcherPhase::PostInputMethod,
|
||||
[this](fcitx::Event &event) {
|
||||
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext focusOut,取消当前会话 active="
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:InputContext focusOut,取消当前会话 active="
|
||||
<< session_.active() << " awaiting=" << awaitingStart_;
|
||||
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
|
||||
});
|
||||
@@ -68,7 +68,7 @@ public:
|
||||
fcitx::EventType::InputContextReset,
|
||||
fcitx::EventWatcherPhase::PostInputMethod,
|
||||
[this](fcitx::Event &event) {
|
||||
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext reset,取消当前会话 active="
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:InputContext reset,取消当前会话 active="
|
||||
<< session_.active() << " awaiting=" << awaitingStart_;
|
||||
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
|
||||
});
|
||||
@@ -79,7 +79,7 @@ public:
|
||||
auto *context = static_cast<fcitx::InputContextEvent &>(event).inputContext();
|
||||
if (context == focusedInputContext_) focusedInputContext_ = nullptr;
|
||||
if (context == inputContext_) {
|
||||
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext destroyed,取消当前会话";
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:InputContext destroyed,取消当前会话";
|
||||
sendControl("request-cancel");
|
||||
resetLocalState(false);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ private:
|
||||
|
||||
if (action == VoiceKeyAction::Start) {
|
||||
event.filterAndAccept();
|
||||
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键按下 held=" << held_
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键按下 held=" << held_
|
||||
<< " awaiting=" << awaitingStart_
|
||||
<< " active=" << session_.active();
|
||||
if (!ensureConnected()) {
|
||||
@@ -132,7 +132,7 @@ private:
|
||||
|
||||
if (action == VoiceKeyAction::Stop) {
|
||||
event.filterAndAccept();
|
||||
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_
|
||||
<< " active=" << session_.active();
|
||||
held_ = false;
|
||||
if (awaitingStart_ || session_.active()) sendControl("request-stop");
|
||||
@@ -143,7 +143,7 @@ private:
|
||||
|
||||
if (event.key().check(FcitxKey_Escape)) {
|
||||
event.filterAndAccept();
|
||||
MELOASR_DIAGNOSTIC_INFO << "MeloASR:录音中收到 Escape,取消当前会话";
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:录音中收到 Escape,取消当前会话";
|
||||
sendControl("request-cancel");
|
||||
clearPreedit();
|
||||
resetLocalState(false);
|
||||
@@ -151,7 +151,7 @@ private:
|
||||
}
|
||||
|
||||
// 用户开始键盘输入时,立即取消语音会话,并让该按键继续交给原输入法。
|
||||
MELOASR_DIAGNOSTIC_INFO << "MeloASR:录音中收到其它按键,取消当前会话 key="
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:录音中收到其它按键,取消当前会话 key="
|
||||
<< event.key().toString() << " rawKey=" << event.rawKey().toString()
|
||||
<< " repeat=" << event.rawKey().states().test(fcitx::KeyState::Repeat);
|
||||
sendControl("request-cancel");
|
||||
@@ -163,7 +163,7 @@ private:
|
||||
if (!context || !context->hasFocus()) return false;
|
||||
if (!context->inputPanel().clientPreedit().empty() ||
|
||||
!context->inputPanel().preedit().empty()) {
|
||||
FCITX_WARN() << "MeloASR:当前输入法存在未提交文本,本轮未启动";
|
||||
FCITX_WARN() << "TamaASR:当前输入法存在未提交文本,本轮未启动";
|
||||
return false;
|
||||
}
|
||||
inputContext_ = context;
|
||||
@@ -179,12 +179,12 @@ private:
|
||||
if (probe > 0 || (probe < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))) {
|
||||
return true;
|
||||
}
|
||||
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:检测到失效的 Electron Socket,立即重连";
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:检测到失效的 Electron Socket,立即重连";
|
||||
handleDisconnect();
|
||||
}
|
||||
const char *runtimeDir = std::getenv("XDG_RUNTIME_DIR");
|
||||
if (!runtimeDir || !*runtimeDir) return false;
|
||||
const std::string socketPath = std::string(runtimeDir) + "/meloasr/fcitx5.sock";
|
||||
const std::string socketPath = std::string(runtimeDir) + "/tama-asr/fcitx5.sock";
|
||||
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) return false;
|
||||
sockaddr_un address{};
|
||||
@@ -267,11 +267,11 @@ private:
|
||||
awaitingStart_ || session_.active()) return;
|
||||
fcitx::Key candidate(message["shortcut"].get<std::string>());
|
||||
if (!candidate.isValid() || !candidate.hasModifier()) {
|
||||
FCITX_WARN() << "MeloASR:拒绝无效快捷键";
|
||||
FCITX_WARN() << "TamaASR:拒绝无效快捷键";
|
||||
return;
|
||||
}
|
||||
trigger_ = std::move(candidate);
|
||||
MELOASR_DIAGNOSTIC_INFO << "MeloASR:快捷键已更新为 " << trigger_.toString();
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:快捷键已更新为 " << trigger_.toString();
|
||||
return;
|
||||
}
|
||||
if (type == "activate") {
|
||||
@@ -285,7 +285,7 @@ private:
|
||||
if (!message.contains("sessionId") || !message["sessionId"].is_string()) return;
|
||||
session_.start(message["sessionId"].get<std::string>());
|
||||
awaitingStart_ = false;
|
||||
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:会话开始,clientPreedit="
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:会话开始,clientPreedit="
|
||||
<< inputContext_->capabilityFlags().test(fcitx::CapabilityFlag::Preedit);
|
||||
// 新会话尚无文本;重复发布空 preedit 会使部分 frontend 立刻 reset。
|
||||
return;
|
||||
@@ -293,7 +293,7 @@ private:
|
||||
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="
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:收到 Electron cancel,sessionIdEmpty="
|
||||
<< incoming.empty() << " message="
|
||||
<< message.value("message", std::string());
|
||||
clearPreedit();
|
||||
@@ -306,7 +306,7 @@ 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>();
|
||||
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:收到 " << type << " revision="
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:收到 " << type << " revision="
|
||||
<< incomingRevision << " bytes=" << currentText_.size();
|
||||
setPreedit(currentText_);
|
||||
if (type == "finish") {
|
||||
@@ -374,7 +374,7 @@ private:
|
||||
}
|
||||
|
||||
void handleDisconnect() {
|
||||
MELOASR_DIAGNOSTIC_INFO << "MeloASR:Electron Socket 断开,取消当前会话 active="
|
||||
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:Electron Socket 断开,取消当前会话 active="
|
||||
<< session_.active() << " awaiting=" << awaitingStart_;
|
||||
clearPreedit();
|
||||
resetLocalState(false);
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"name": "meloasr",
|
||||
"productName": "MeloASR",
|
||||
"version": "0.1.10",
|
||||
"name": "tama-asr",
|
||||
"productName": "TamaASR",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"author": "MeloASR contributors",
|
||||
"packageManager": "pnpm@11.21.0",
|
||||
"author": "TamaASR contributors",
|
||||
"description": "基于网页语音识别后端和 Fcitx5 的 Linux 实时语音输入工具",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"clean": "node scripts/clean.mjs",
|
||||
"build": "npm run clean && tsc && esbuild src/preload/backend.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/backend.js && node scripts/copy-static.mjs",
|
||||
"build": "pnpm run clean && tsc && esbuild src/preload/backend.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/backend.js && node scripts/copy-static.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "npm run build && electron --ozone-platform=x11 .",
|
||||
"start": "pnpm run build && electron --ozone-platform=x11 .",
|
||||
"build:fcitx5": "cmake -S linux/fcitx5 -B linux/fcitx5/build -DCMAKE_BUILD_TYPE=Release && cmake --build linux/fcitx5/build --parallel",
|
||||
"build:linux:dir": "npm run build && electron-builder --config packaging/electron-builder.yml --linux dir",
|
||||
"package:deb": "npm run build:linux:dir && packaging/deb/build-deb.sh",
|
||||
"package:rpm": "npm run build:linux:dir && packaging/rpm/build-rpm.sh",
|
||||
"test": "npm run typecheck && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
|
||||
"build:linux:dir": "pnpm run build && electron-builder --config packaging/electron-builder.yml --linux dir",
|
||||
"package:deb": "pnpm run build:linux:dir && packaging/deb/build-deb.sh",
|
||||
"package:rpm": "pnpm run build:linux:dir && packaging/rpm/build-rpm.sh",
|
||||
"test": "pnpm run typecheck && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
# Linux 分发打包
|
||||
|
||||
MeloASR 0.1.10 的系统包由两部分组成:
|
||||
TamaASR 1.0.1 的系统包由两部分组成:
|
||||
|
||||
- Electron 应用安装到 `/opt/meloasr`,命令入口为 `/usr/bin/meloasr`;
|
||||
- Electron 应用安装到 `/opt/tama-asr`,命令入口为 `/usr/bin/tama-asr`;
|
||||
- Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。
|
||||
|
||||
安装包同时提供桌面入口、项目图标和 `/etc/xdg/autostart/meloasr.desktop`,因此安装后的默认行为是随桌面会话启动。设置页关闭自启动时,应用需要在用户级 autostart 目录写入覆盖项,不能删除系统包所属文件。
|
||||
安装包同时提供桌面入口、项目图标和 `/etc/xdg/autostart/tama-asr.desktop`,因此安装后的默认行为是随桌面会话启动。设置页关闭自启动时,应用需要在用户级 autostart 目录写入覆盖项,不能删除系统包所属文件。
|
||||
|
||||
`tama-asr` 1.0.1 的 deb、RPM 和 Arch 元数据均声明替换旧 `meloasr` 包;通过对应发行版的包管理器安装新包时,旧应用、Fcitx5 addon 和桌面文件会一并卸载。
|
||||
|
||||
## 统一构建契约
|
||||
|
||||
deb、rpm 和 Arch 配置期望项目提供:
|
||||
|
||||
```bash
|
||||
npm run build:linux:dir
|
||||
pnpm run build:linux:dir
|
||||
```
|
||||
|
||||
该命令应在 `release/linux-unpacked/` 生成可直接运行的 Electron 目录,并且主程序名必须是 `meloasr`。仓库提供的 `electron-builder.yml` 只生成这一中间目录;deb/rpm/Arch 的系统文件仍由各自脚本管理,避免用安装钩子复制未被包管理器追踪的 Fcitx5 文件。
|
||||
该命令应在 `release/linux-unpacked/` 生成可直接运行的 Electron 目录,并且主程序名必须是 `tama-asr`。仓库提供的 `electron-builder.yml` 只生成这一中间目录;deb/rpm/Arch 的系统文件仍由各自脚本管理,避免用安装钩子复制未被包管理器追踪的 Fcitx5 文件。
|
||||
|
||||
主项目可将脚本定义为:
|
||||
|
||||
```json
|
||||
{
|
||||
"build:linux:dir": "npm run build && electron-builder --config packaging/electron-builder.yml --linux dir"
|
||||
"build:linux:dir": "pnpm run build && electron-builder --config packaging/electron-builder.yml --linux dir"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -36,18 +38,18 @@ packaging/scripts/stage-linux.sh release/linux-unpacked packaging/out/root
|
||||
在 Debian/Ubuntu 对应目标环境中安装 Node.js 构建依赖、CMake、C++ 编译器、Fcitx5 开发包与 nlohmann-json 开发包,然后执行:
|
||||
|
||||
```bash
|
||||
npm run build:linux:dir
|
||||
pnpm run build:linux:dir
|
||||
packaging/deb/build-deb.sh
|
||||
```
|
||||
|
||||
产物位于 `packaging/out/meloasr_0.1.10_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
|
||||
产物位于 `packaging/out/tama-asr_1.0.1_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
|
||||
|
||||
## rpm
|
||||
|
||||
在目标 RPM 发行版中准备相同的编译依赖和 `rpmbuild`,然后执行:
|
||||
|
||||
```bash
|
||||
npm run build:linux:dir
|
||||
pnpm run build:linux:dir
|
||||
packaging/rpm/build-rpm.sh
|
||||
```
|
||||
|
||||
@@ -55,7 +57,7 @@ packaging/rpm/build-rpm.sh
|
||||
|
||||
## Arch/pacman
|
||||
|
||||
参见 [`arch/README.md`](arch/README.md)。PKGBUILD 在干净 chroot 中从源码构建 Electron 应用和 Fcitx5 addon。
|
||||
参见 [`arch/README.md`](arch/README.md)。默认 `PKGBUILD` 解包同版本官方 deb 并重打包为 pacman 包;`PKGBUILD-from-source` 保留在干净 chroot 中构建 Electron 应用和 Fcitx5 addon 的原有流程。
|
||||
|
||||
## Nix flake
|
||||
|
||||
@@ -64,12 +66,12 @@ nix build
|
||||
nix run
|
||||
```
|
||||
|
||||
flake 使用 nixpkgs 的 Electron,Node 依赖由 `package-lock.json` 的 integrity 数据导入,不需要手写固定输出 hash。仓库首次确定发布地址后,需要锁定并提交 `flake.lock`。当前环境没有 Nix,尚未实际执行上述命令。
|
||||
flake 使用 nixpkgs 的 Electron,Node 依赖由 `pnpm-lock.yaml` 和固定输出的 pnpm store 导入。仓库首次确定发布地址后,需要锁定并提交 `flake.lock`。
|
||||
|
||||
## 发布前必须完成
|
||||
|
||||
- 发布前确认仓库地址和许可证元数据;
|
||||
- 明确项目许可证,并替换临时的 `LicenseRef-Proprietary`/Nix license;
|
||||
- 生成 Arch 源码归档真实 SHA-256;
|
||||
- 生成 Arch bin 版 deb 和源码版源码归档的真实 SHA-256;
|
||||
- 在干净的 Debian、RPM、Arch 和 Nix 构建环境逐一构建、安装、卸载;
|
||||
- 验证 Fcitx5 能发现 addon,桌面菜单/托盘图标、自启动开关和包升级均正常。
|
||||
|
||||
@@ -1,45 +1,33 @@
|
||||
pkgname=meloasr
|
||||
pkgver=0.1.10
|
||||
pkgname=tama-asr
|
||||
pkgver=1.0.1
|
||||
pkgrel=1
|
||||
pkgdesc='Web-backed voice input for Fcitx5'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/Melorise/MeloASR'
|
||||
arch=('x86_64' 'aarch64')
|
||||
url='https://github.com/Melorise/TamaASR'
|
||||
license=('MPL2')
|
||||
depends=('fcitx5' 'gtk3' 'nss' 'libxss' 'mesa' 'alsa-lib')
|
||||
makedepends=('nodejs' 'npm' 'cmake' 'gcc' 'nlohmann-json')
|
||||
source=("${pkgname}-${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
npm ci
|
||||
npm run build:linux:dir
|
||||
cmake -S linux/fcitx5 -B build/fcitx5 \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr
|
||||
cmake --build build/fcitx5 --parallel
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
npm test
|
||||
ctest --test-dir build/fcitx5 --output-on-failure
|
||||
}
|
||||
conflicts=('meloasr')
|
||||
provides=('meloasr')
|
||||
replaces=('meloasr')
|
||||
options=('!strip')
|
||||
source_x86_64=("${pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/${pkgname}_${pkgver}_amd64.deb")
|
||||
source_aarch64=("${pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/v${pkgver}/${pkgname}_${pkgver}_arm64.deb")
|
||||
sha256sums_x86_64=('SKIP')
|
||||
sha256sums_aarch64=('SKIP')
|
||||
noextract=("${pkgname}-${pkgver}-x86_64.deb" "${pkgname}-${pkgver}-aarch64.deb")
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
local deb_file="${srcdir}/${pkgname}-${pkgver}-${CARCH}.deb"
|
||||
local deb_root="${srcdir}/deb-root"
|
||||
local data_archives
|
||||
|
||||
install -d "${pkgdir}/opt/meloasr"
|
||||
cp -a release/linux-unpacked/. "${pkgdir}/opt/meloasr/"
|
||||
install -Dm755 packaging/assets/meloasr-wrapper "${pkgdir}/usr/bin/meloasr"
|
||||
install -Dm644 packaging/assets/meloasr.desktop \
|
||||
"${pkgdir}/usr/share/applications/meloasr.desktop"
|
||||
install -Dm644 packaging/assets/meloasr-autostart.desktop \
|
||||
"${pkgdir}/etc/xdg/autostart/meloasr.desktop"
|
||||
install -Dm644 packaging/assets/meloasr.metainfo.xml \
|
||||
"${pkgdir}/usr/share/metainfo/meloasr.metainfo.xml"
|
||||
install -Dm644 src/assets/logo.png \
|
||||
"${pkgdir}/usr/share/pixmaps/meloasr.png"
|
||||
|
||||
DESTDIR="${pkgdir}" cmake --install build/fcitx5
|
||||
rm -rf -- "${deb_root}"
|
||||
mkdir -p "${deb_root}"
|
||||
bsdtar -xf "${deb_file}" -C "${deb_root}"
|
||||
data_archives=("${deb_root}"/data.tar.*)
|
||||
if (( ${#data_archives[@]} != 1 )) || [[ ! -f "${data_archives[0]}" ]]; then
|
||||
error '无法从 deb 包中找到唯一的 data.tar.* 归档'
|
||||
return 1
|
||||
fi
|
||||
bsdtar -xf "${data_archives[0]}" -C "${pkgdir}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
pkgname=tama-asr
|
||||
pkgver=1.0.1
|
||||
pkgrel=1
|
||||
pkgdesc='Web-backed voice input for Fcitx5'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/Melorise/TamaASR'
|
||||
license=('MPL2')
|
||||
depends=('fcitx5' 'gtk3' 'nss' 'libxss' 'mesa' 'alsa-lib')
|
||||
conflicts=('meloasr')
|
||||
provides=('meloasr')
|
||||
replaces=('meloasr')
|
||||
makedepends=('nodejs' 'pnpm' 'cmake' 'gcc' 'nlohmann-json')
|
||||
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
_source_dir="TamaASR-${pkgver}"
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${_source_dir}"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build:linux:dir
|
||||
cmake -S linux/fcitx5 -B build/fcitx5 \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr
|
||||
cmake --build build/fcitx5 --parallel
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "${srcdir}/${_source_dir}"
|
||||
pnpm test
|
||||
ctest --test-dir build/fcitx5 --output-on-failure
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${_source_dir}"
|
||||
|
||||
install -d "${pkgdir}/opt/tama-asr"
|
||||
cp -a release/linux-unpacked/. "${pkgdir}/opt/tama-asr/"
|
||||
install -Dm755 packaging/assets/tama-asr-wrapper "${pkgdir}/usr/bin/tama-asr"
|
||||
install -Dm644 packaging/assets/tama-asr.desktop \
|
||||
"${pkgdir}/usr/share/applications/tama-asr.desktop"
|
||||
install -Dm644 packaging/assets/tama-asr-autostart.desktop \
|
||||
"${pkgdir}/etc/xdg/autostart/tama-asr.desktop"
|
||||
install -Dm644 packaging/assets/tama-asr.metainfo.xml \
|
||||
"${pkgdir}/usr/share/metainfo/tama-asr.metainfo.xml"
|
||||
install -Dm644 src/assets/logo.png \
|
||||
"${pkgdir}/usr/share/pixmaps/tama-asr.png"
|
||||
|
||||
DESTDIR="${pkgdir}" cmake --install build/fcitx5
|
||||
}
|
||||
@@ -1,9 +1,27 @@
|
||||
# Arch Linux 构建
|
||||
# Arch Linux 打包
|
||||
|
||||
发布时把项目源码归档为 `meloasr-0.1.10.tar.gz`,与 `PKGBUILD` 放在同一目录,随后运行:
|
||||
## 默认:从官方 deb 重打包
|
||||
|
||||
远程 Release 附带的 `PKGBUILD` 是 bin 版。它根据当前 Arch 架构下载同一 Release 中的 `amd64.deb` 或 `arm64.deb`,解出已经构建好的 Electron 应用、桌面文件和 Fcitx5 addon,再交给 `makepkg` 生成 pacman 包,不会在本地重复编译源码。
|
||||
|
||||
下载后直接运行:
|
||||
|
||||
```bash
|
||||
curl --fail --location --remote-name \
|
||||
https://github.com/Melorise/TamaASR/releases/download/v1.0.1/PKGBUILD
|
||||
makepkg --syncdeps --cleanbuild
|
||||
```
|
||||
|
||||
正式发布必须把 `source` 改为仓库的不可变 release URL,并以 `updpkgsums` 写入真实 SHA-256;当前源码包仍保留 `SKIP`,不能直接作为正式 Arch 发布源。
|
||||
`x86_64` 使用 `tama-asr_<version>_amd64.deb`,`aarch64` 使用 `tama-asr_<version>_arm64.deb`。这两个 deb 与 Arch 包来自同一版本的官方 Release。
|
||||
|
||||
## 可选:从源码构建
|
||||
|
||||
需要完整源码构建时,使用 Release 同时附带的 `PKGBUILD-from-source`:
|
||||
|
||||
```bash
|
||||
curl --fail --location --remote-name \
|
||||
https://github.com/Melorise/TamaASR/releases/download/v1.0.1/PKGBUILD-from-source
|
||||
makepkg --syncdeps --cleanbuild -p PKGBUILD-from-source
|
||||
```
|
||||
|
||||
源码版保留原有流程:下载对应 Git 标签的源码归档,安装锁文件依赖,构建 Electron 应用和 Fcitx5 addon,运行测试后再生成 pacman 包。
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=MeloASR
|
||||
Comment=Start MeloASR when signing in
|
||||
Comment[zh_CN]=登录桌面后启动 MeloASR
|
||||
Exec=meloasr --autostart
|
||||
Icon=meloasr
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
X-KDE-autostart-after=panel
|
||||
NoDisplay=true
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec /opt/meloasr/meloasr --ozone-platform=x11 "$@"
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.meloasr.MeloASR</id>
|
||||
<name>MeloASR</name>
|
||||
<summary>Web-backed voice input for Fcitx5</summary>
|
||||
<summary xml:lang="zh-CN">基于网页后端的 Fcitx5 语音输入工具</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MPL-2.0</project_license>
|
||||
<description>
|
||||
<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://github.com/Melorise/MeloASR</url>
|
||||
<launchable type="desktop-id">meloasr.desktop</launchable>
|
||||
<provides>
|
||||
<binary>meloasr</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="0.1.10" date="2026-08-23"/>
|
||||
<release version="0.1.9" date="2026-08-23"/>
|
||||
<release version="0.1.8" date="2026-08-23"/>
|
||||
<release version="0.1.7" date="2026-08-23"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=TamaASR
|
||||
Comment=Start TamaASR when signing in
|
||||
Comment[zh_CN]=登录桌面后启动 TamaASR
|
||||
Exec=tama-asr --autostart
|
||||
Icon=tama-asr
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
X-KDE-autostart-after=panel
|
||||
NoDisplay=true
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /opt/tama-asr/tama-asr --ozone-platform=x11 "$@"
|
||||
@@ -1,10 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=MeloASR
|
||||
Name=TamaASR
|
||||
Comment=Web-backed voice input for Fcitx5
|
||||
Comment[zh_CN]=基于网页后端的 Fcitx5 语音输入工具
|
||||
Exec=meloasr
|
||||
Icon=meloasr
|
||||
Exec=tama-asr
|
||||
Icon=tama-asr
|
||||
Terminal=false
|
||||
Categories=Utility;Accessibility;
|
||||
StartupNotify=false
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.melorise.TamaASR</id>
|
||||
<name>TamaASR</name>
|
||||
<summary>Web-backed voice input for Fcitx5</summary>
|
||||
<summary xml:lang="zh-CN">基于网页后端的 Fcitx5 语音输入工具</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MPL-2.0</project_license>
|
||||
<description>
|
||||
<p>TamaASR connects supported web speech-recognition backends to the active Fcitx5 input context.</p>
|
||||
<p xml:lang="zh-CN">TamaASR 将支持的网页语音识别后端连接到当前 Fcitx5 输入上下文。</p>
|
||||
</description>
|
||||
<url type="homepage">https://github.com/Melorise/TamaASR</url>
|
||||
<launchable type="desktop-id">tama-asr.desktop</launchable>
|
||||
<provides>
|
||||
<binary>tama-asr</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="1.0.1" date="2026-09-01"/>
|
||||
<release version="1.0.0" date="2026-09-01"/>
|
||||
<release version="0.1.17" date="2026-09-01"/>
|
||||
<release version="0.1.16" date="2026-08-26"/>
|
||||
<release version="0.1.15" date="2026-08-26"/>
|
||||
<release version="0.1.14" date="2026-08-26"/>
|
||||
<release version="0.1.13" date="2026-08-24"/>
|
||||
<release version="0.1.12" date="2026-08-23"/>
|
||||
<release version="0.1.11" date="2026-08-23"/>
|
||||
<release version="0.1.10" date="2026-08-23"/>
|
||||
<release version="0.1.9" date="2026-08-23"/>
|
||||
<release version="0.1.8" date="2026-08-23"/>
|
||||
<release version="0.1.7" date="2026-08-23"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -2,9 +2,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
version="${MELOASR_VERSION:-0.1.10}"
|
||||
arch="${MELOASR_DEB_ARCH:-$(dpkg --print-architecture)}"
|
||||
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
|
||||
version="${TAMA_ASR_VERSION:-1.0.1}"
|
||||
arch="${TAMA_ASR_DEB_ARCH:-$(dpkg --print-architecture)}"
|
||||
app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
|
||||
work_dir="${project_dir}/packaging/out/deb-root"
|
||||
output_dir="${project_dir}/packaging/out"
|
||||
|
||||
@@ -21,4 +21,4 @@ sed \
|
||||
|
||||
mkdir -p "${output_dir}"
|
||||
dpkg-deb --root-owner-group --build "${work_dir}" \
|
||||
"${output_dir}/meloasr_${version}_${arch}.deb"
|
||||
"${output_dir}/tama-asr_${version}_${arch}.deb"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
Package: meloasr
|
||||
Package: tama-asr
|
||||
Version: @VERSION@
|
||||
Section: sound
|
||||
Priority: optional
|
||||
Architecture: @ARCH@
|
||||
Maintainer: MeloASR contributors
|
||||
Maintainer: TamaASR contributors
|
||||
Installed-Size: @INSTALLED_SIZE@
|
||||
Depends: fcitx5, libc6, libstdc++6, libgtk-3-0, libnss3, libxss1, libgbm1, libasound2 | libasound2t64
|
||||
Conflicts: meloasr
|
||||
Replaces: meloasr
|
||||
Description: Web-backed voice input for Fcitx5
|
||||
MeloASR hosts supported web speech-recognition backends and sends their
|
||||
TamaASR hosts supported web speech-recognition backends and sends their
|
||||
live corrected text to the active Fcitx5 input context.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: io.github.meloasr.MeloASR
|
||||
productName: MeloASR
|
||||
appId: io.github.melorise.TamaASR
|
||||
productName: TamaASR
|
||||
asar: true
|
||||
|
||||
directories:
|
||||
@@ -16,6 +16,6 @@ extraResources:
|
||||
linux:
|
||||
target:
|
||||
- dir
|
||||
executableName: meloasr
|
||||
executableName: tama-asr
|
||||
icon: src/assets/logo.png
|
||||
category: Utility
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{ lib
|
||||
, stdenv
|
||||
, importNpmLock
|
||||
, fetchPnpmDeps
|
||||
, nodejs
|
||||
, pnpm_11
|
||||
, pnpmConfigHook
|
||||
, electron
|
||||
, cmake
|
||||
, pkg-config
|
||||
@@ -10,19 +12,26 @@
|
||||
, nlohmann_json
|
||||
}:
|
||||
|
||||
let
|
||||
pnpm = pnpm_11;
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "meloasr";
|
||||
pname = "tama-asr";
|
||||
version = (builtins.fromJSON (builtins.readFile ../../package.json)).version;
|
||||
|
||||
src = ../..;
|
||||
|
||||
npmDeps = importNpmLock {
|
||||
npmRoot = finalAttrs.src;
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
inherit pnpm;
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-KoxRIhZVhshxTpl5rzzfUnaSAuGo3gCvFD2FsltN1gc=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
nodejs
|
||||
importNpmLock.npmConfigHook
|
||||
pnpm
|
||||
pnpmConfigHook
|
||||
cmake
|
||||
pkg-config
|
||||
makeWrapper
|
||||
@@ -30,7 +39,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
buildInputs = [ fcitx5 nlohmann_json ];
|
||||
|
||||
# 保留 npmConfigHook 的 postConfigure 生命周期,但不对项目根目录运行默认 CMake 配置。
|
||||
# 保留 pnpmConfigHook 的 postConfigure 生命周期,但不对项目根目录运行默认 CMake 配置。
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
runHook postConfigure
|
||||
@@ -38,7 +47,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
npm run build
|
||||
pnpm run build
|
||||
cmake -S linux/fcitx5 -B build/fcitx5 \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="$out" \
|
||||
@@ -49,7 +58,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
checkPhase = ''
|
||||
runHook preCheck
|
||||
npm test
|
||||
pnpm test
|
||||
ctest --test-dir build/fcitx5 --output-on-failure
|
||||
runHook postCheck
|
||||
'';
|
||||
@@ -58,29 +67,24 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
appDir="$out/share/meloasr/app"
|
||||
appDir="$out/share/tama-asr/app"
|
||||
mkdir -p "$appDir" "$out/bin" "$out/share/applications" \
|
||||
"$out/share/pixmaps" "$out/share/metainfo" \
|
||||
"$out/etc/xdg/autostart"
|
||||
|
||||
cp package.json "$appDir/"
|
||||
cp -r dist "$appDir/"
|
||||
if [ -d node_modules ]; then
|
||||
npm prune --omit=dev --ignore-scripts
|
||||
cp -r node_modules "$appDir/"
|
||||
fi
|
||||
|
||||
install -Dm644 packaging/assets/meloasr.desktop \
|
||||
"$out/share/applications/meloasr.desktop"
|
||||
install -Dm644 packaging/assets/meloasr-autostart.desktop \
|
||||
"$out/etc/xdg/autostart/meloasr.desktop"
|
||||
install -Dm644 packaging/assets/meloasr.metainfo.xml \
|
||||
"$out/share/metainfo/meloasr.metainfo.xml"
|
||||
install -Dm644 packaging/assets/tama-asr.desktop \
|
||||
"$out/share/applications/tama-asr.desktop"
|
||||
install -Dm644 packaging/assets/tama-asr-autostart.desktop \
|
||||
"$out/etc/xdg/autostart/tama-asr.desktop"
|
||||
install -Dm644 packaging/assets/tama-asr.metainfo.xml \
|
||||
"$out/share/metainfo/tama-asr.metainfo.xml"
|
||||
install -Dm644 src/assets/logo.png \
|
||||
"$out/share/pixmaps/meloasr.png"
|
||||
"$out/share/pixmaps/tama-asr.png"
|
||||
|
||||
cmake --install build/fcitx5
|
||||
makeWrapper ${electron}/bin/electron "$out/bin/meloasr" \
|
||||
makeWrapper ${electron}/bin/electron "$out/bin/tama-asr" \
|
||||
--add-flags "$appDir"
|
||||
|
||||
runHook postInstall
|
||||
@@ -88,9 +92,9 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
meta = {
|
||||
description = "Web-backed voice input for Fcitx5";
|
||||
homepage = "https://github.com/Melorise/MeloASR";
|
||||
homepage = "https://github.com/Melorise/TamaASR";
|
||||
license = lib.licenses.mpl20;
|
||||
mainProgram = "meloasr";
|
||||
mainProgram = "tama-asr";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
version="${MELOASR_VERSION:-0.1.10}"
|
||||
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
|
||||
version="${TAMA_ASR_VERSION:-1.0.1}"
|
||||
app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
|
||||
stage_dir="${project_dir}/packaging/out/rpm-root"
|
||||
top_dir="${project_dir}/packaging/out/rpmbuild"
|
||||
|
||||
@@ -14,19 +14,19 @@ command -v rpmbuild >/dev/null || {
|
||||
|
||||
rm -rf -- "${stage_dir}" "${top_dir}"
|
||||
"${project_dir}/packaging/scripts/stage-linux.sh" "${app_dir}" "${stage_dir}"
|
||||
addon_file="$(find "${stage_dir}/usr" -type f -path '*/fcitx5/libmeloasr.so' \
|
||||
addon_file="$(find "${stage_dir}/usr" -type f -path '*/fcitx5/libtama-asr.so' \
|
||||
-printf '/usr/%P\n' -quit)"
|
||||
if [[ -z "${addon_file}" ]]; then
|
||||
echo "未在安装根目录中找到 Fcitx5 addon。" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${top_dir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
|
||||
tar -C "${stage_dir}" -czf "${top_dir}/SOURCES/meloasr-root.tar.gz" .
|
||||
tar -C "${stage_dir}" -czf "${top_dir}/SOURCES/tama-asr-root.tar.gz" .
|
||||
|
||||
rpmbuild -bb \
|
||||
--define "_topdir ${top_dir}" \
|
||||
--define "meloasr_version ${version}" \
|
||||
--define "meloasr_addon_file ${addon_file}" \
|
||||
"${project_dir}/packaging/rpm/meloasr-binary.spec"
|
||||
--define "tama_asr_version ${version}" \
|
||||
--define "tama_asr_addon_file ${addon_file}" \
|
||||
"${project_dir}/packaging/rpm/tama-asr-binary.spec"
|
||||
|
||||
find "${top_dir}/RPMS" -type f -name '*.rpm' -exec cp -f {} "${project_dir}/packaging/out/" \;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
Name: meloasr
|
||||
Version: %{meloasr_version}
|
||||
Release: 1%{?dist}
|
||||
Summary: Web-backed voice input for Fcitx5
|
||||
License: MPL-2.0
|
||||
URL: https://github.com/Melorise/MeloASR
|
||||
Source0: meloasr-root.tar.gz
|
||||
|
||||
Requires: fcitx5
|
||||
Requires: gtk3
|
||||
Requires: nss
|
||||
Requires: libXScrnSaver
|
||||
Requires: mesa-libgbm
|
||||
|
||||
%description
|
||||
MeloASR hosts supported web speech-recognition backends and sends their live
|
||||
corrected text to the active Fcitx5 input context.
|
||||
|
||||
%prep
|
||||
|
||||
%build
|
||||
|
||||
%install
|
||||
mkdir -p %{buildroot}
|
||||
tar -C %{buildroot} -xzf %{SOURCE0}
|
||||
|
||||
%files
|
||||
%defattr(-,root,root,-)
|
||||
/opt/meloasr
|
||||
/usr/bin/meloasr
|
||||
%{meloasr_addon_file}
|
||||
/usr/share/applications/meloasr.desktop
|
||||
/usr/share/pixmaps/meloasr.png
|
||||
/usr/share/metainfo/meloasr.metainfo.xml
|
||||
/usr/share/fcitx5/addon/meloasr.conf
|
||||
/etc/xdg/autostart/meloasr.desktop
|
||||
|
||||
%changelog
|
||||
* Sun Aug 23 2026 MeloASR contributors - 0.1.10-1
|
||||
- Restore the Nix npm configuration hook before building the application.
|
||||
|
||||
* Sun Aug 23 2026 MeloASR contributors - 0.1.9-1
|
||||
- Fix multi-architecture package staging and RPM addon file ownership.
|
||||
- Fix Nix derivation configuration phase.
|
||||
|
||||
* Sun Aug 23 2026 MeloASR contributors - 0.1.8-1
|
||||
- Fix Fcitx5 addon compatibility with Ubuntu 24.04.
|
||||
|
||||
* Sun Aug 23 2026 MeloASR contributors - 0.1.7-1
|
||||
- Initial package
|
||||
@@ -0,0 +1,80 @@
|
||||
Name: tama-asr
|
||||
Version: %{tama_asr_version}
|
||||
Release: 1%{?dist}
|
||||
Summary: Web-backed voice input for Fcitx5
|
||||
License: MPL-2.0
|
||||
URL: https://github.com/Melorise/TamaASR
|
||||
Source0: tama-asr-root.tar.gz
|
||||
|
||||
Requires: fcitx5
|
||||
Requires: gtk3
|
||||
Requires: nss
|
||||
Requires: libXScrnSaver
|
||||
Requires: mesa-libgbm
|
||||
Obsoletes: meloasr < 1.0.0
|
||||
Provides: meloasr = %{version}-%{release}
|
||||
|
||||
%description
|
||||
TamaASR hosts supported web speech-recognition backends and sends their live
|
||||
corrected text to the active Fcitx5 input context.
|
||||
|
||||
%prep
|
||||
|
||||
%build
|
||||
|
||||
%install
|
||||
mkdir -p %{buildroot}
|
||||
tar -C %{buildroot} -xzf %{SOURCE0}
|
||||
|
||||
%files
|
||||
%defattr(-,root,root,-)
|
||||
/opt/tama-asr
|
||||
/usr/bin/tama-asr
|
||||
%{tama_asr_addon_file}
|
||||
/usr/share/applications/tama-asr.desktop
|
||||
/usr/share/pixmaps/tama-asr.png
|
||||
/usr/share/metainfo/tama-asr.metainfo.xml
|
||||
/usr/share/fcitx5/addon/tama-asr.conf
|
||||
/etc/xdg/autostart/tama-asr.desktop
|
||||
|
||||
%changelog
|
||||
* Tue Sep 01 2026 TamaASR contributors - 1.0.1-1
|
||||
- Redesign the settings and overlay-position interfaces.
|
||||
|
||||
* Tue Sep 01 2026 TamaASR contributors - 1.0.0-1
|
||||
- Rename the package and replace the legacy meloasr package on upgrade.
|
||||
|
||||
* Tue Sep 01 2026 TamaASR contributors - 0.1.17-1
|
||||
- Replace the application and tray icons; use one icon for ready and one for all not-ready states.
|
||||
|
||||
* Wed Aug 26 2026 TamaASR contributors - 0.1.16-1
|
||||
- Migrate dependency installation and build commands from npm to pnpm.
|
||||
|
||||
* Wed Aug 26 2026 TamaASR contributors - 0.1.15-1
|
||||
- Download the Arch package source directly from the matching GitHub release tag.
|
||||
|
||||
* Wed Aug 26 2026 TamaASR contributors - 0.1.14-1
|
||||
- Cache the active web editor for the duration of each speech session.
|
||||
|
||||
* Mon Aug 24 2026 TamaASR contributors - 0.1.13-1
|
||||
- Create the settings renderer only when it is opened and destroy it on close.
|
||||
|
||||
* Sun Aug 23 2026 TamaASR contributors - 0.1.12-1
|
||||
- Disable Electron GPU acceleration for background web renderers.
|
||||
|
||||
* Sun Aug 23 2026 TamaASR contributors - 0.1.11-1
|
||||
- Release only the selected speech backend renderer.
|
||||
- Keep the overlay as a non-interactive session status indicator.
|
||||
|
||||
* Sun Aug 23 2026 TamaASR contributors - 0.1.10-1
|
||||
- Restore the Nix npm configuration hook before building the application.
|
||||
|
||||
* Sun Aug 23 2026 TamaASR contributors - 0.1.9-1
|
||||
- Fix multi-architecture package staging and RPM addon file ownership.
|
||||
- Fix Nix derivation configuration phase.
|
||||
|
||||
* Sun Aug 23 2026 TamaASR contributors - 0.1.8-1
|
||||
- Fix Fcitx5 addon compatibility with Ubuntu 24.04.
|
||||
|
||||
* Sun Aug 23 2026 TamaASR contributors - 0.1.7-1
|
||||
- Initial package
|
||||
@@ -2,9 +2,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
|
||||
app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
|
||||
stage_dir="${2:-${project_dir}/packaging/out/root}"
|
||||
addon_build_dir="${MELOASR_ADDON_BUILD_DIR:-${project_dir}/packaging/out/fcitx5-build}"
|
||||
addon_build_dir="${TAMA_ASR_ADDON_BUILD_DIR:-${project_dir}/packaging/out/fcitx5-build}"
|
||||
|
||||
if [[ "${app_dir}" != /* ]]; then
|
||||
app_dir="${project_dir}/${app_dir}"
|
||||
@@ -16,30 +16,30 @@ if [[ ! -d "${app_dir}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "${app_dir}/meloasr" ]]; then
|
||||
echo "Electron 解包目录中没有可执行文件 meloasr:${app_dir}" >&2
|
||||
if [[ ! -x "${app_dir}/tama-asr" ]]; then
|
||||
echo "Electron 解包目录中没有可执行文件 tama-asr:${app_dir}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -d \
|
||||
"${stage_dir}/opt/meloasr" \
|
||||
"${stage_dir}/opt/tama-asr" \
|
||||
"${stage_dir}/usr/bin" \
|
||||
"${stage_dir}/usr/share/applications" \
|
||||
"${stage_dir}/usr/share/pixmaps" \
|
||||
"${stage_dir}/usr/share/metainfo" \
|
||||
"${stage_dir}/etc/xdg/autostart"
|
||||
|
||||
cp -a "${app_dir}/." "${stage_dir}/opt/meloasr/"
|
||||
install -Dm755 "${project_dir}/packaging/assets/meloasr-wrapper" \
|
||||
"${stage_dir}/usr/bin/meloasr"
|
||||
install -Dm644 "${project_dir}/packaging/assets/meloasr.desktop" \
|
||||
"${stage_dir}/usr/share/applications/meloasr.desktop"
|
||||
install -Dm644 "${project_dir}/packaging/assets/meloasr-autostart.desktop" \
|
||||
"${stage_dir}/etc/xdg/autostart/meloasr.desktop"
|
||||
install -Dm644 "${project_dir}/packaging/assets/meloasr.metainfo.xml" \
|
||||
"${stage_dir}/usr/share/metainfo/meloasr.metainfo.xml"
|
||||
cp -a "${app_dir}/." "${stage_dir}/opt/tama-asr/"
|
||||
install -Dm755 "${project_dir}/packaging/assets/tama-asr-wrapper" \
|
||||
"${stage_dir}/usr/bin/tama-asr"
|
||||
install -Dm644 "${project_dir}/packaging/assets/tama-asr.desktop" \
|
||||
"${stage_dir}/usr/share/applications/tama-asr.desktop"
|
||||
install -Dm644 "${project_dir}/packaging/assets/tama-asr-autostart.desktop" \
|
||||
"${stage_dir}/etc/xdg/autostart/tama-asr.desktop"
|
||||
install -Dm644 "${project_dir}/packaging/assets/tama-asr.metainfo.xml" \
|
||||
"${stage_dir}/usr/share/metainfo/tama-asr.metainfo.xml"
|
||||
install -Dm644 "${project_dir}/src/assets/logo.png" \
|
||||
"${stage_dir}/usr/share/pixmaps/meloasr.png"
|
||||
"${stage_dir}/usr/share/pixmaps/tama-asr.png"
|
||||
|
||||
cmake -S "${project_dir}/linux/fcitx5" -B "${addon_build_dir}" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
electron: true
|
||||
electron-winstaller: false
|
||||
esbuild: true
|
||||
@@ -8,10 +8,8 @@ const copies = [
|
||||
['src/renderer/overlay.html', 'dist/renderer/overlay.html'],
|
||||
['src/renderer/overlay.css', 'dist/renderer/overlay.css'],
|
||||
['src/assets/logo.png', 'dist/assets/logo.png'],
|
||||
['src/assets/tray-loading.png', 'dist/assets/tray-loading.png'],
|
||||
['src/assets/tray-notready.png', 'dist/assets/tray-notready.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) {
|
||||
|
||||
|
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 398 B |
@@ -5,7 +5,7 @@ export const qianwenDefinition: BackendDefinition = Object.freeze({
|
||||
label: '千问',
|
||||
url: 'https://www.qianwen.com/',
|
||||
origins: Object.freeze(['https://www.qianwen.com']),
|
||||
partition: 'persist:meloasr-qianwen',
|
||||
partition: 'persist:tama-asr-qianwen',
|
||||
editorSelector: '[data-slate-editor="true"][contenteditable="true"], [role="textbox"][contenteditable="true"]'
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export const yuanbaoDefinition: BackendDefinition = Object.freeze({
|
||||
label: '元宝',
|
||||
url: 'https://yuanbao.tencent.com/',
|
||||
origins: Object.freeze(['https://yuanbao.tencent.com']),
|
||||
partition: 'persist:meloasr-yuanbao',
|
||||
partition: 'persist:tama-asr-yuanbao',
|
||||
editorSelector: '.ql-editor[contenteditable="true"], [contenteditable="true"][role="textbox"]'
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
import { resolveAutoStartAction } from './auto-start-policy';
|
||||
|
||||
export class AutoStartService {
|
||||
private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'meloasr.desktop');
|
||||
private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'tama-asr.desktop');
|
||||
|
||||
apply(enabled: boolean): void {
|
||||
const action = resolveAutoStartAction(app.isPackaged, enabled);
|
||||
@@ -15,7 +15,7 @@ export class AutoStartService {
|
||||
fs.writeFileSync(this.filePath, [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=MeloASR',
|
||||
'Name=TamaASR',
|
||||
'Hidden=true',
|
||||
''
|
||||
].join('\n'), { mode: 0o644 });
|
||||
|
||||
@@ -12,6 +12,7 @@ interface BackendRecord {
|
||||
page: BrowserWindow;
|
||||
windows: Set<BrowserWindow>;
|
||||
exposed: boolean;
|
||||
disposed: boolean;
|
||||
status: BackendRuntimeStatus;
|
||||
}
|
||||
|
||||
@@ -53,13 +54,13 @@ export class BackendManager extends EventEmitter {
|
||||
skipTaskbar: true,
|
||||
width: 1280,
|
||||
height: 840,
|
||||
title: `${definition.label} · MeloASR`,
|
||||
title: `${definition.label} · TamaASR`,
|
||||
backgroundColor: '#ffffff',
|
||||
icon: path.join(app.getAppPath(), 'dist', 'assets', 'logo.png'),
|
||||
webPreferences: {
|
||||
partition: definition.partition,
|
||||
preload: path.join(__dirname, '..', 'preload', 'backend.js'),
|
||||
additionalArguments: [`--meloasr-backend=${id}`],
|
||||
additionalArguments: [`--tama-asr-backend=${id}`],
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
@@ -72,6 +73,7 @@ export class BackendManager extends EventEmitter {
|
||||
page: root,
|
||||
windows: new Set([root]),
|
||||
exposed: false,
|
||||
disposed: false,
|
||||
status: loadingStatus(definition)
|
||||
};
|
||||
this.records.set(id, record);
|
||||
@@ -89,7 +91,12 @@ export class BackendManager extends EventEmitter {
|
||||
|
||||
async select(id: string): Promise<void> {
|
||||
requireBackend(id);
|
||||
this.hideRecord(this.records.get(this.settings.get().backendId));
|
||||
const previousId = this.settings.get().backendId;
|
||||
if (previousId === id) {
|
||||
await this.ensure(id);
|
||||
return;
|
||||
}
|
||||
this.disposeRecord(previousId);
|
||||
this.settings.update({ backendId: id });
|
||||
await this.ensure(id);
|
||||
this.emit('active-changed', id);
|
||||
@@ -200,7 +207,7 @@ export class BackendManager extends EventEmitter {
|
||||
|
||||
private registerWindow(window: BrowserWindow, record: BackendRecord): void {
|
||||
window.on('close', (event) => {
|
||||
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
|
||||
if (record.disposed || (app as typeof app & { isQuitting?: boolean }).isQuitting) return;
|
||||
event.preventDefault();
|
||||
this.hideRecord(record);
|
||||
});
|
||||
@@ -209,6 +216,10 @@ export class BackendManager extends EventEmitter {
|
||||
overrideBrowserWindowOptions: { show: record.exposed, skipTaskbar: !record.exposed, focusable: true }
|
||||
}));
|
||||
window.webContents.on('did-create-window', (child) => {
|
||||
if (record.disposed) {
|
||||
child.destroy();
|
||||
return;
|
||||
}
|
||||
record.windows.add(child);
|
||||
child.on('closed', () => record.windows.delete(child));
|
||||
this.registerWindow(child, record);
|
||||
@@ -241,6 +252,17 @@ export class BackendManager extends EventEmitter {
|
||||
for (const window of record.windows) this.parkWindow(window);
|
||||
}
|
||||
|
||||
private disposeRecord(id: string): void {
|
||||
const record = this.records.get(id);
|
||||
if (!record) return;
|
||||
record.disposed = true;
|
||||
this.records.delete(id);
|
||||
for (const window of [...record.windows]) {
|
||||
if (!window.isDestroyed()) window.destroy();
|
||||
}
|
||||
record.windows.clear();
|
||||
}
|
||||
|
||||
private parkWindow(window: BrowserWindow): void {
|
||||
if (window.isDestroyed()) return;
|
||||
window.hide();
|
||||
|
||||
@@ -8,7 +8,7 @@ const MAX_LINE_BYTES = 1024 * 1024;
|
||||
|
||||
export function defaultSocketPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (!env.XDG_RUNTIME_DIR) throw new Error('缺少 XDG_RUNTIME_DIR,无法创建 Fcitx5 本地 Socket');
|
||||
return path.join(env.XDG_RUNTIME_DIR, 'meloasr', 'fcitx5.sock');
|
||||
return path.join(env.XDG_RUNTIME_DIR, 'tama-asr', 'fcitx5.sock');
|
||||
}
|
||||
|
||||
export class JsonLineDecoder {
|
||||
|
||||
@@ -9,14 +9,16 @@ import { SettingsWindow } from './settings-window';
|
||||
import { TrayController } from './tray-controller';
|
||||
import type { BackendStatusPayload, Point } from '../shared/contracts';
|
||||
|
||||
app.setName('MeloASR');
|
||||
app.setName('TamaASR');
|
||||
app.disableHardwareAcceleration();
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
const singleInstance = app.requestSingleInstanceLock();
|
||||
|
||||
if (!singleInstance) {
|
||||
app.quit();
|
||||
} else {
|
||||
void app.whenReady().then(bootstrap).catch((error) => {
|
||||
console.error('[MeloASR 启动失败]', error);
|
||||
console.error('[TamaASR 启动失败]', error);
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
@@ -34,12 +36,10 @@ async function bootstrap(): Promise<void> {
|
||||
const overlay = new OverlayController(settings);
|
||||
await overlay.create();
|
||||
const sessions = new SessionController(backends, bridge, overlay);
|
||||
const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() ||
|
||||
'https://github.com/Melorise/MeloASR';
|
||||
const repositoryUrl = process.env.TAMA_ASR_REPOSITORY_URL?.trim() ||
|
||||
'https://github.com/Melorise/TamaASR';
|
||||
const settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl);
|
||||
const settingsWindowReady = settingsWindow.create();
|
||||
registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow });
|
||||
await settingsWindowReady;
|
||||
const tray = new TrayController(
|
||||
backends,
|
||||
settingsWindow,
|
||||
@@ -66,7 +66,7 @@ async function bootstrap(): Promise<void> {
|
||||
await backends.ensure(settings.get().backendId);
|
||||
tray.refresh();
|
||||
|
||||
app.on('second-instance', () => settingsWindow.show());
|
||||
app.on('second-instance', () => void settingsWindow.show());
|
||||
app.on('before-quit', () => { (app as typeof app & { isQuitting?: boolean }).isQuitting = true; });
|
||||
app.on('will-quit', () => {
|
||||
tray.destroy();
|
||||
@@ -88,9 +88,6 @@ function registerIpc(services: Services): void {
|
||||
const { settings, autoStart, bridge, backends, overlay, sessions, settingsWindow } = services;
|
||||
const settingsSender = (senderId: number): boolean =>
|
||||
Boolean(settingsWindow.window && !settingsWindow.window.isDestroyed() && settingsWindow.window.webContents.id === senderId);
|
||||
const overlaySender = (senderId: number): boolean =>
|
||||
Boolean(overlay.window && !overlay.window.isDestroyed() && overlay.window.webContents.id === senderId);
|
||||
|
||||
ipcMain.handle('settings:get-state', (event) => {
|
||||
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的设置请求');
|
||||
return settingsWindow.state();
|
||||
@@ -115,10 +112,6 @@ function registerIpc(services: Services): void {
|
||||
overlay.applyPreset(preset, displayId);
|
||||
return settingsWindow.state();
|
||||
});
|
||||
ipcMain.handle('settings:preview-overlay', (event) => {
|
||||
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();
|
||||
@@ -141,12 +134,6 @@ function registerIpc(services: Services): void {
|
||||
settings.update({ autoStart: enabled });
|
||||
return settingsWindow.state();
|
||||
});
|
||||
ipcMain.handle('settings:set-overlay-persistent', (event, enabled: unknown) => {
|
||||
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的悬浮球设置');
|
||||
settings.update({ overlayPersistent: enabled });
|
||||
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 });
|
||||
@@ -157,9 +144,6 @@ function registerIpc(services: Services): void {
|
||||
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求');
|
||||
await settingsWindow.openRepository();
|
||||
});
|
||||
ipcMain.on('overlay:toggle-recording', (event) => {
|
||||
if (overlaySender(event.sender.id)) sessions.requestStartFromOverlay();
|
||||
});
|
||||
ipcMain.on('backend-status', (event, payload: BackendStatusPayload) => backends.handleStatus(event, payload));
|
||||
ipcMain.on('backend-transcript', (event, payload: { backend?: string; text?: string }) => {
|
||||
if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) {
|
||||
@@ -180,7 +164,7 @@ function registerIpc(services: Services): void {
|
||||
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)}`);
|
||||
console.info(`[TamaASR 网页诊断] backend=${backend} stage=${stage || 'unknown'} ${JSON.stringify(details)}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ export class OverlayController {
|
||||
}
|
||||
});
|
||||
this.window.setAlwaysOnTop(true, 'floating');
|
||||
this.window.setIgnoreMouseEvents(true);
|
||||
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'overlay.html'));
|
||||
this.position();
|
||||
this.reconcilePersistent();
|
||||
}
|
||||
|
||||
position(point = this.settings.get().overlayPosition): Point {
|
||||
@@ -68,7 +68,6 @@ export class OverlayController {
|
||||
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();
|
||||
}
|
||||
@@ -77,8 +76,7 @@ export class OverlayController {
|
||||
if (!this.positioning) return;
|
||||
this.positioning = false;
|
||||
if (!this.window || this.window.isDestroyed()) return;
|
||||
this.window.setIgnoreMouseEvents(false);
|
||||
this.reconcilePersistent();
|
||||
this.window.hide();
|
||||
}
|
||||
|
||||
showState(state: SessionState | 'done' | 'error'): void {
|
||||
@@ -90,26 +88,9 @@ export class OverlayController {
|
||||
|
||||
hideLater(delay = 900): void {
|
||||
this.clearHideTimer();
|
||||
if (this.settings.get().overlayPersistent) return;
|
||||
this.hideTimer = setTimeout(() => this.window?.hide(), delay);
|
||||
}
|
||||
|
||||
preview(): void {
|
||||
this.position();
|
||||
this.showState('recording');
|
||||
this.hideLater(1800);
|
||||
}
|
||||
|
||||
reconcilePersistent(): void {
|
||||
if (!this.window || this.window.isDestroyed()) return;
|
||||
if (this.positioning) return;
|
||||
if (this.settings.get().overlayPersistent) {
|
||||
this.showState('idle');
|
||||
} else {
|
||||
this.window.hide();
|
||||
}
|
||||
}
|
||||
|
||||
private clearHideTimer(): void {
|
||||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||||
this.hideTimer = null;
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { BackendManager } from './backend-manager';
|
||||
import type { FcitxBridge } from './fcitx-bridge';
|
||||
import type { OverlayController } from './overlay-controller';
|
||||
|
||||
const COMPLETION_INDICATOR_DURATION_MS = 450;
|
||||
|
||||
export class SessionController extends EventEmitter {
|
||||
state: SessionState = 'idle';
|
||||
private mirroredText = '';
|
||||
@@ -54,14 +56,6 @@ export class SessionController extends EventEmitter {
|
||||
if (this.stopRequestedDuringStart) this.stop();
|
||||
}
|
||||
|
||||
requestStartFromOverlay(): void {
|
||||
if (this.state === 'recording' || this.state === 'starting') {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
if (!this.bridge.ready || !this.bridge.requestInputStart()) this.reject('Fcitx5 插件尚未连接');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.state === 'starting') {
|
||||
this.stopRequestedDuringStart = true;
|
||||
@@ -78,7 +72,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}`);
|
||||
console.warn(`[TamaASR 会话取消] state=${this.state} reason=${message}`);
|
||||
this.generation += 1;
|
||||
this.stopRequestedDuringStart = false;
|
||||
this.backends.activePage()?.webContents.send('backend-control', {
|
||||
@@ -105,12 +99,12 @@ export class SessionController extends EventEmitter {
|
||||
this.state = 'idle';
|
||||
this.mirroredText = '';
|
||||
this.overlay.showState('done');
|
||||
this.overlay.hideLater();
|
||||
this.overlay.hideLater(COMPLETION_INDICATOR_DURATION_MS);
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
backendError(message: string): void {
|
||||
console.error(`[MeloASR 后端错误] state=${this.state} reason=${message}`);
|
||||
console.error(`[TamaASR 后端错误] state=${this.state} reason=${message}`);
|
||||
if (this.bridge.activeSession) this.bridge.cancel(); else this.bridge.rejectStart(message);
|
||||
this.state = 'idle';
|
||||
this.mirroredText = '';
|
||||
@@ -133,7 +127,7 @@ export class SessionController extends EventEmitter {
|
||||
}
|
||||
|
||||
private notify(message: string): void {
|
||||
if (Notification.isSupported()) new Notification({ title: 'MeloASR', body: message }).show();
|
||||
if (Notification.isSupported()) new Notification({ title: 'TamaASR', body: message }).show();
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
|
||||
@@ -8,7 +8,6 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
backendId: 'qianwen',
|
||||
shortcut: 'Control+Shift+space',
|
||||
autoStart: true,
|
||||
overlayPersistent: false,
|
||||
diagnosticLogging: false,
|
||||
overlayPosition: null,
|
||||
loginNoticeShown: {}
|
||||
@@ -22,10 +21,12 @@ export class SettingsStore extends EventEmitter {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as Partial<AppSettings> & {
|
||||
backend?: string;
|
||||
overlayPersistent?: unknown;
|
||||
};
|
||||
const { overlayPersistent: _obsoleteOverlayPersistent, ...saved } = parsed;
|
||||
this.value = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...parsed,
|
||||
...saved,
|
||||
backendId: parsed.backendId ?? parsed.backend ?? DEFAULT_SETTINGS.backendId,
|
||||
overlayPosition: this.isPoint(parsed.overlayPosition) ? {
|
||||
x: Math.round(parsed.overlayPosition.x), y: Math.round(parsed.overlayPosition.y)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { SettingsStore } from './settings-store';
|
||||
|
||||
export class SettingsWindow {
|
||||
window: BrowserWindow | null = null;
|
||||
private creating: Promise<BrowserWindow> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly settings: SettingsStore,
|
||||
@@ -18,14 +19,25 @@ export class SettingsWindow {
|
||||
private readonly repositoryUrl: string | null
|
||||
) {}
|
||||
|
||||
async create(): Promise<void> {
|
||||
this.window = new BrowserWindow({
|
||||
width: 720,
|
||||
height: 820,
|
||||
minWidth: 640,
|
||||
minHeight: 680,
|
||||
private async ensureWindow(): Promise<BrowserWindow> {
|
||||
if (this.window && !this.window.isDestroyed()) return this.window;
|
||||
if (this.creating) return this.creating;
|
||||
this.creating = this.createWindow();
|
||||
try {
|
||||
return await this.creating;
|
||||
} finally {
|
||||
this.creating = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async createWindow(): Promise<BrowserWindow> {
|
||||
const window = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 650,
|
||||
minWidth: 560,
|
||||
minHeight: 560,
|
||||
show: false,
|
||||
title: 'MeloASR 设置',
|
||||
title: 'TamaASR 设置',
|
||||
autoHideMenuBar: true,
|
||||
icon: path.join(app.getAppPath(), 'dist', 'assets', 'logo.png'),
|
||||
webPreferences: {
|
||||
@@ -35,18 +47,19 @@ export class SettingsWindow {
|
||||
sandbox: true
|
||||
}
|
||||
});
|
||||
this.window.on('close', (event) => {
|
||||
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
|
||||
event.preventDefault();
|
||||
this.window?.hide();
|
||||
this.window = window;
|
||||
window.on('closed', () => {
|
||||
this.overlay.endPositioning();
|
||||
if (this.window === window) this.window = null;
|
||||
});
|
||||
this.window.on('hide', () => this.overlay.endPositioning());
|
||||
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
|
||||
await window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
|
||||
return window;
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this.window?.show();
|
||||
this.window?.focus();
|
||||
async show(): Promise<void> {
|
||||
const window = await this.ensureWindow();
|
||||
window.show();
|
||||
window.focus();
|
||||
this.publish();
|
||||
}
|
||||
|
||||
@@ -60,7 +73,7 @@ export class SettingsWindow {
|
||||
const definition = this.backends.activeDefinition();
|
||||
const decision = resolveDebugLoginAction(Boolean(current.loginNoticeShown[definition.id]), confirmed);
|
||||
if (decision.action === 'prompt') {
|
||||
this.show();
|
||||
await this.show();
|
||||
this.window?.webContents.send('settings:login-notice', { backendLabel: definition.label });
|
||||
return;
|
||||
}
|
||||
@@ -81,12 +94,11 @@ export class SettingsWindow {
|
||||
sessionState: this.sessions.state,
|
||||
shortcut: current.shortcut,
|
||||
autoStart: current.autoStart,
|
||||
overlayPersistent: current.overlayPersistent,
|
||||
diagnosticLogging: current.diagnosticLogging,
|
||||
overlayPosition: position,
|
||||
displays: screen.getAllDisplays().map((display, index) => ({
|
||||
id: String(display.id),
|
||||
label: `屏幕 ${index + 1}(${display.workArea.width} × ${display.workArea.height})`,
|
||||
label: `屏幕 ${index + 1}`,
|
||||
workArea: display.workArea
|
||||
})),
|
||||
version: app.getVersion(),
|
||||
|
||||
@@ -6,10 +6,10 @@ import type { SettingsWindow } from './settings-window';
|
||||
type TrayState = 'loading' | 'ready' | 'login-required' | 'error';
|
||||
|
||||
const ICONS: Record<TrayState, string> = {
|
||||
loading: 'tray-loading.png',
|
||||
loading: 'tray-notready.png',
|
||||
ready: 'tray-ready.png',
|
||||
'login-required': 'tray-login-required.png',
|
||||
error: 'tray-error.png'
|
||||
'login-required': 'tray-notready.png',
|
||||
error: 'tray-notready.png'
|
||||
};
|
||||
|
||||
function trayImage(state: TrayState): Electron.NativeImage {
|
||||
@@ -30,8 +30,8 @@ export class TrayController {
|
||||
|
||||
create(): void {
|
||||
this.tray = new Tray(trayImage('loading'));
|
||||
this.tray.setToolTip('MeloASR · 正在加载');
|
||||
this.tray.on('click', () => this.settingsWindow.show());
|
||||
this.tray.setToolTip('TamaASR · 正在加载');
|
||||
this.tray.on('click', () => void this.settingsWindow.show());
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ export class TrayController {
|
||||
: status.login === 'logged-out' ? 'login-required'
|
||||
: status.detail.startsWith('加载失败') ? 'error' : 'loading';
|
||||
this.tray.setImage(trayImage(state));
|
||||
this.tray.setToolTip(`MeloASR · ${status.detail}`);
|
||||
this.tray.setToolTip(`TamaASR · ${status.detail}`);
|
||||
this.tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: '设置', click: () => this.settingsWindow.show() },
|
||||
{ label: '设置', click: () => void this.settingsWindow.show() },
|
||||
{ label: '打开登录/调试页面', click: () => void this.settingsWindow.openDebug() },
|
||||
{ type: 'separator' },
|
||||
{ label: '重启', click: this.onRestart },
|
||||
|
||||
@@ -5,12 +5,13 @@ import { shouldClickMicrophoneOnCancel } from './speech-session-state';
|
||||
import { shouldFinishAfterStop } from './stop-completion';
|
||||
|
||||
const backendId = process.argv
|
||||
.find((argument) => argument.startsWith('--meloasr-backend='))
|
||||
?.slice('--meloasr-backend='.length) ?? '';
|
||||
.find((argument) => argument.startsWith('--tama-asr-backend='))
|
||||
?.slice('--tama-asr-backend='.length) ?? '';
|
||||
const adapter = getBackendWebAdapter(backendId);
|
||||
|
||||
let editorObserver: MutationObserver | undefined;
|
||||
let pageObserver: MutationObserver | undefined;
|
||||
let activeEditor: HTMLElement | null = null;
|
||||
let active = false;
|
||||
let stopping = false;
|
||||
let lastSpeechText = '';
|
||||
@@ -68,12 +69,11 @@ function schedulePageStatus(): void {
|
||||
function emitCurrentEditorText(): void {
|
||||
emitScheduled = false;
|
||||
if (!active || !adapter) return;
|
||||
const editor = adapter.findEditor(document);
|
||||
if (!editor) {
|
||||
if (!activeEditor?.isConnected) {
|
||||
fail(`${adapter.definition.label}输入框在录音期间消失,本轮已停止`);
|
||||
return;
|
||||
}
|
||||
const text = adapter.serialize(editor);
|
||||
const text = adapter.serialize(activeEditor);
|
||||
if (text === lastSpeechText) return;
|
||||
lastSpeechText = text;
|
||||
transcriptRevision += 1;
|
||||
@@ -119,6 +119,7 @@ function resetSession(): void {
|
||||
stopping = false;
|
||||
lastTranscriptAt = 0;
|
||||
editorObserver?.disconnect();
|
||||
activeEditor = null;
|
||||
diagnose('session-reset');
|
||||
}
|
||||
|
||||
@@ -147,28 +148,30 @@ function startSpeech(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialText = adapter.serialize(editor);
|
||||
diagnose('start-targets', {
|
||||
editorLength: adapter.serialize(editor).length,
|
||||
editorLength: initialText.length,
|
||||
microphone: controlSnapshot(microphone)
|
||||
});
|
||||
|
||||
clearTimeout(stopTimer);
|
||||
placeCaretAtEnd(editor);
|
||||
lastSpeechText = adapter.serialize(editor);
|
||||
lastSpeechText = initialText;
|
||||
transcriptRevision = 0;
|
||||
lastTranscriptAt = Date.now();
|
||||
active = true;
|
||||
stopping = false;
|
||||
activeEditor = editor;
|
||||
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
|
||||
editorLength: active && editor.isConnected ? adapter.serialize(editor).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
|
||||
editorLength: active && editor.isConnected ? adapter.serialize(editor).length : null
|
||||
}), 1_000);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('meloOverlay', {
|
||||
toggleRecording: (): void => ipcRenderer.send('overlay:toggle-recording'),
|
||||
onState: (listener: (state: string) => void): void => {
|
||||
ipcRenderer.on('overlay-state', (_event, payload: { state: string }) => listener(payload.state));
|
||||
}
|
||||
|
||||
@@ -9,13 +9,10 @@ contextBridge.exposeInMainWorld('meloSettings', {
|
||||
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'),
|
||||
|
||||
@@ -8,19 +8,16 @@ declare global {
|
||||
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;
|
||||
onState(listener: (state: string) => void): void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
|
||||
body { display: grid; place-items: center; }
|
||||
.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; padding: 0; border: 0; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); cursor: pointer; }
|
||||
.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); }
|
||||
.ring { position: absolute; inset: 0; border: 2px solid rgba(69,136,245,.45); border-radius: 50%; opacity: 0; }
|
||||
.voice-overlay[data-state="recording"] .ring { animation: ripple 1.7s ease-out infinite; }
|
||||
.voice-overlay[data-state="recording"] .ring.two { animation-delay: .55s; }
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<link rel="stylesheet" href="overlay.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="voice-overlay" data-state="idle" aria-label="开始或停止语音输入">
|
||||
<div class="voice-overlay" data-state="idle" aria-hidden="true">
|
||||
<span class="ring one"></span><span class="ring two"></span>
|
||||
<span class="bars"><i></i><i></i><i></i><i></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<script src="overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
const root = document.querySelector<HTMLElement>('.voice-overlay')!;
|
||||
|
||||
root.addEventListener('click', () => window.meloOverlay.toggleRecording());
|
||||
root.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') window.meloOverlay.toggleRecording();
|
||||
});
|
||||
window.meloOverlay.onState((state) => { root.dataset.state = state; });
|
||||
|
||||
@@ -1,56 +1,254 @@
|
||||
:root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; }
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: #202421;
|
||||
background: #f6f7f5;
|
||||
--brand: #63a978;
|
||||
--brand-strong: #4f9564;
|
||||
--brand-soft: #edf6ef;
|
||||
--text: #202421;
|
||||
--text-secondary: #707672;
|
||||
--text-tertiary: #929792;
|
||||
--border: #e2e6e1;
|
||||
--border-strong: #cfd5cf;
|
||||
--surface: #fff;
|
||||
--surface-muted: #f8f9f7;
|
||||
--warning: #a66b20;
|
||||
--warning-soft: #fff7e9;
|
||||
--error: #b34d43;
|
||||
--error-soft: #fff0ee;
|
||||
--ease-out: cubic-bezier(.23, 1, .32, 1);
|
||||
--ease-in-out: cubic-bezier(.77, 0, .175, 1);
|
||||
}
|
||||
|
||||
* { 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; }
|
||||
header h1 { margin: 0; font-size: 22px; }
|
||||
header span { color: #788398; }
|
||||
main { display: grid; gap: 14px; padding: 0 28px 30px; }
|
||||
section { padding: 18px; background: white; border: 1px solid #e0e5ee; border-radius: 13px; box-shadow: 0 5px 20px rgba(33,48,77,.05); }
|
||||
h2 { margin: 0 0 14px; font-size: 16px; }
|
||||
.row { display: flex; align-items: center; gap: 12px; min-height: 40px; }
|
||||
.row > label, .row > span:first-child { min-width: 80px; color: #5f6b80; }
|
||||
select, input, button { height: 38px; border-radius: 8px; font: inherit; }
|
||||
select, input { padding: 0 10px; border: 1px solid #cad3e1; background: white; }
|
||||
select { flex: 1; }
|
||||
button { padding: 0 14px; border: 0; background: #e9eef7; color: #26334a; cursor: pointer; font-weight: 600; }
|
||||
button.primary { background: #286df0; color: white; }
|
||||
button:disabled { opacity: .55; cursor: default; }
|
||||
.status { padding: 4px 9px; border-radius: 99px; font-size: 12px; background: #edf0f5; }
|
||||
.status[data-state="ready"] { color: #087b4d; background: #ddf6eb; }
|
||||
.status[data-state="warning"] { color: #a84332; background: #fde8e4; }
|
||||
.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; }
|
||||
.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; }
|
||||
html, body { min-width: 100%; min-height: 100%; }
|
||||
body { margin: 0; overflow-x: hidden; background: #f6f7f5; }
|
||||
button, select, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
button, select { -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, select:focus-visible, input:focus-visible {
|
||||
outline: 2px solid rgba(99, 169, 120, .42);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.view { width: 100%; padding: 20px 24px; }
|
||||
.settings-view { display: grid; gap: 12px; max-width: 720px; margin: 0 auto; }
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 1px 2px rgba(33, 46, 36, .025), 0 7px 24px rgba(33, 46, 36, .025);
|
||||
}
|
||||
|
||||
.app-header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.brand img { width: 42px; height: 42px; border-radius: 11px; object-fit: cover; box-shadow: 0 2px 8px rgba(67, 112, 78, .12); }
|
||||
.brand h1, .position-header h1 { margin: 0; font-size: 20px; line-height: 1.2; letter-spacing: -.018em; }
|
||||
.brand p, .position-header p, .section-heading p { margin: 3px 0 0; color: var(--text-secondary); }
|
||||
.brand p { font-size: 13px; }
|
||||
.app-status {
|
||||
display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px;
|
||||
border: 1px solid #d4e6d8; border-radius: 9px; color: #417d53; background: #f3f9f4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-status i, .backend-status i { display: block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.app-status[data-state="loading"] { border-color: var(--border); color: var(--text-secondary); background: var(--surface-muted); }
|
||||
.app-status[data-state="warning"] { border-color: #eeddbf; color: var(--warning); background: var(--warning-soft); }
|
||||
|
||||
.service-card { padding: 17px 18px 16px; }
|
||||
.section-heading h2, .preferences-card h2, .position-tools h2 { margin: 0; font-size: 16px; line-height: 1.25; letter-spacing: -.01em; }
|
||||
.section-heading p { font-size: 13px; }
|
||||
.service-row { display: grid; grid-template-columns: 92px minmax(150px, 1fr) auto; align-items: center; gap: 12px; margin-top: 15px; }
|
||||
.service-row > label { font-weight: 600; }
|
||||
.select-shell { position: relative; min-width: 0; }
|
||||
.select-shell::after {
|
||||
content: ""; position: absolute; right: 13px; top: 50%; width: 7px; height: 7px;
|
||||
border-right: 1.5px solid #5f665f; border-bottom: 1.5px solid #5f665f;
|
||||
transform: translateY(-70%) rotate(45deg); pointer-events: none;
|
||||
}
|
||||
select {
|
||||
width: 100%; height: 38px; padding: 0 34px 0 12px; appearance: none;
|
||||
border: 1px solid var(--border-strong); border-radius: 8px; color: var(--text); background: var(--surface);
|
||||
cursor: pointer; transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
select:focus { border-color: #8abb98; }
|
||||
.backend-status { display: inline-flex; align-items: center; gap: 7px; min-width: 74px; color: var(--text-secondary); white-space: nowrap; }
|
||||
.backend-status[data-state="ready"] { color: #478b5b; }
|
||||
.backend-status[data-state="warning"] { color: var(--warning); }
|
||||
.backend-status[data-state="loading"] i { background: #a5aaa6; }
|
||||
.warning {
|
||||
display: flex; align-items: flex-start; gap: 8px; margin-top: 12px; padding: 9px 11px;
|
||||
border: 1px solid #f0dfbf; border-radius: 8px; color: #83571e; background: var(--warning-soft); font-size: 12px;
|
||||
}
|
||||
.warning svg { flex: 0 0 auto; width: 17px; height: 17px; }
|
||||
.service-actions { display: flex; justify-content: flex-end; margin-top: 13px; }
|
||||
.primary-button, .secondary-button, .icon-button, .direction-pad button {
|
||||
border: 0; border-radius: 8px; cursor: pointer;
|
||||
transition: transform 140ms var(--ease-out), background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.primary-button { height: 38px; padding: 0 15px; color: #fff; background: var(--brand); font-weight: 600; box-shadow: 0 2px 7px rgba(79, 149, 100, .16); }
|
||||
.primary-button:hover { background: var(--brand-strong); }
|
||||
.primary-button:disabled, .secondary-button:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.preferences-card { padding: 16px 18px 2px; }
|
||||
.preferences-card > h2 { margin-bottom: 6px; }
|
||||
.preference-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 60px; border-top: 1px solid #ecefeb; }
|
||||
.preferences-card > h2 + .preference-row { border-top: 0; }
|
||||
.preference-copy { display: grid; gap: 2px; min-width: 0; }
|
||||
.preference-copy strong { font-size: 14px; font-weight: 600; }
|
||||
.preference-copy span { color: var(--text-secondary); font-size: 12px; }
|
||||
.shortcut {
|
||||
flex: 0 0 auto; min-width: 148px; height: 38px; padding: 0 13px; border: 1px solid var(--border-strong);
|
||||
border-radius: 8px; color: #353a36; background: linear-gradient(#fff, #fafbfa); cursor: pointer;
|
||||
box-shadow: inset 0 -1px 0 rgba(30, 40, 32, .035); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.shortcut[data-capturing="true"] { border-color: #88b995; color: #39794c; background: var(--brand-soft); }
|
||||
.toggle-row { cursor: pointer; user-select: none; }
|
||||
.toggle { position: relative; flex: 0 0 auto; width: 45px; height: 26px; }
|
||||
.toggle input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
|
||||
.toggle > span {
|
||||
display: block; width: 45px; height: 26px; border-radius: 999px; background: #cdd1cd;
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
.toggle > span::after {
|
||||
content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%;
|
||||
background: #fff; box-shadow: 0 1px 4px rgba(25, 36, 28, .18);
|
||||
transform: translateX(0); transition: transform 180ms var(--ease-out);
|
||||
}
|
||||
.toggle input:checked + span { background: var(--brand); }
|
||||
.toggle input:checked + span::after { transform: translateX(19px); }
|
||||
.toggle input:focus-visible + span { outline: 2px solid rgba(99, 169, 120, .42); outline-offset: 2px; }
|
||||
|
||||
.position-link {
|
||||
display: flex; align-items: center; width: 100%; min-height: 72px; padding: 11px 16px;
|
||||
color: var(--text); text-align: left; cursor: pointer;
|
||||
transition: transform 140ms var(--ease-out), border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
.position-icon { display: grid; place-items: center; width: 44px; height: 44px; margin-right: 13px; border: 1px solid #b8dbbf; border-radius: 50%; background: #f6fbf7; }
|
||||
.position-icon img { width: 31px; height: 31px; border-radius: 50%; object-fit: cover; }
|
||||
.position-link-copy { display: grid; gap: 3px; flex: 1; }
|
||||
.position-link-copy strong { font-size: 14px; }
|
||||
.position-link-copy span { color: var(--text-secondary); font-size: 12px; }
|
||||
.chevron { width: 21px; height: 21px; color: #6e756f; }
|
||||
.trust-notice {
|
||||
padding: 13px 15px; border: 1px solid #dce7de; border-radius: 10px;
|
||||
color: #646b66; background: #f8faf8; font-size: 11px; line-height: 1.6;
|
||||
}
|
||||
.trust-notice p { margin: 0; }
|
||||
.trust-notice p + p { margin-top: 6px; }
|
||||
.trust-notice .license-note { color: #4b805a; font-weight: 600; }
|
||||
.app-footer { display: flex; align-items: center; justify-content: space-between; min-height: 26px; padding: 0 7px; color: var(--text-secondary); font-size: 12px; }
|
||||
.repository-link { display: inline-flex; align-items: center; gap: 3px; padding: 4px 0; border: 0; color: #4a8c5e; background: transparent; cursor: pointer; }
|
||||
.repository-link svg { width: 15px; height: 15px; }
|
||||
.repository-link:disabled { color: var(--text-tertiary); cursor: default; }
|
||||
|
||||
.position-view { max-width: 780px; margin: 0 auto; }
|
||||
.position-header { display: flex; align-items: center; gap: 13px; min-height: 49px; margin-bottom: 12px; }
|
||||
.position-header p { font-size: 13px; }
|
||||
.icon-button { display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid var(--border); background: var(--surface); }
|
||||
.icon-button svg { width: 20px; height: 20px; }
|
||||
.position-panel { padding: 15px 16px 14px; }
|
||||
.display-row { display: grid; grid-template-columns: 90px minmax(230px, 1fr); align-items: center; gap: 16px; }
|
||||
.display-row label { font-size: 15px; font-weight: 600; }
|
||||
.display-row .select-shell { justify-self: end; width: min(100%, 315px); }
|
||||
|
||||
.position-screen {
|
||||
position: relative; height: 250px; margin-top: 14px; overflow: hidden;
|
||||
border: 1px solid #cfd7d0; border-radius: 9px;
|
||||
background: linear-gradient(160deg, #f2f7f3 0%, #e9f1eb 45%, #dce9df 100%);
|
||||
}
|
||||
.position-screen::before, .position-screen::after {
|
||||
content: ""; position: absolute; left: -8%; width: 116%; height: 62%; border-radius: 50%; pointer-events: none;
|
||||
}
|
||||
.position-screen::before { bottom: -35%; background: rgba(150, 183, 159, .23); transform: rotate(2deg); }
|
||||
.position-screen::after { bottom: -47%; background: rgba(126, 165, 137, .18); transform: rotate(-4deg); }
|
||||
.desktop-bar { position: relative; z-index: 2; display: flex; align-items: center; justify-content: space-between; height: 22px; padding: 0 9px; border-bottom: 1px solid #d3dad4; color: #69706a; background: rgba(251, 252, 251, .88); font-size: 9px; }
|
||||
.desktop-status { letter-spacing: 1px; }
|
||||
.desktop-window {
|
||||
position: absolute; z-index: 1; width: 38%; height: 53%; border: 1px solid rgba(148, 158, 150, .58);
|
||||
border-radius: 5px; background: rgba(255, 255, 255, .86); box-shadow: 0 8px 22px rgba(61, 76, 64, .055);
|
||||
}
|
||||
.desktop-window::before { content: ""; display: block; height: 13px; border-bottom: 1px solid #e0e4e0; }
|
||||
.desktop-window i { position: absolute; top: 4px; width: 4px; height: 4px; border-radius: 50%; background: #c4cac5; }
|
||||
.desktop-window i:nth-child(1) { right: 17px; }.desktop-window i:nth-child(2) { right: 10px; }.desktop-window i:nth-child(3) { right: 3px; }
|
||||
.desktop-window span { display: block; width: 52%; height: 7px; margin: 11px 12px -4px; border-radius: 3px; background: #ecefec; }
|
||||
.desktop-window span:nth-of-type(2) { width: 38%; }.desktop-window span:nth-of-type(3) { width: 68%; }
|
||||
.window-back { top: 28%; left: 17%; }.window-front { top: 46%; left: 38%; width: 35%; height: 47%; }
|
||||
.position-ball {
|
||||
position: absolute; z-index: 4; width: 50px; height: 50px; padding: 5px; border: 2px solid var(--brand);
|
||||
border-radius: 50%; background: rgba(247, 251, 248, .78); box-shadow: 0 6px 18px rgba(66, 113, 78, .2);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.position-ball img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
|
||||
|
||||
.position-tools { display: grid; grid-template-columns: minmax(145px, .82fr) minmax(265px, 1.55fr); gap: 18px; margin-top: 14px; }
|
||||
.position-tools h2 { margin-bottom: 9px; font-size: 13px; }
|
||||
.preset-section { padding-right: 18px; border-right: 1px solid var(--border); }
|
||||
.preset-grid { display: grid; grid-template-columns: repeat(3, 1fr); place-items: center; gap: 12px 18px; min-height: 118px; padding: 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-muted); }
|
||||
.preset-grid button {
|
||||
position: relative; width: 18px; height: 18px; padding: 0; border: 1.5px solid #aeb5af; border-radius: 50%; background: #fff; cursor: pointer;
|
||||
}
|
||||
.preset-grid button[aria-pressed="true"] { border-color: var(--brand); background: var(--brand); box-shadow: 0 0 0 6px #e7f3ea; }
|
||||
.precise-controls { display: grid; grid-template-columns: minmax(125px, 1fr) 132px; align-items: center; gap: 16px; }
|
||||
.coordinates { display: grid; gap: 10px; }
|
||||
.coordinates label { display: grid; grid-template-columns: 35px 1fr; height: 38px; border: 1px solid var(--border-strong); border-radius: 8px; background: #fff; overflow: hidden; }
|
||||
.coordinates label > span { display: grid; place-items: center; border-right: 1px solid var(--border); color: var(--text-secondary); }
|
||||
.coordinates input { width: 100%; min-width: 0; height: 100%; padding: 0 10px; border: 0; color: var(--text); background: transparent; font-variant-numeric: tabular-nums; }
|
||||
.coordinates input:focus-visible { outline-offset: -2px; }
|
||||
.direction-pad { display: grid; grid-template-columns: repeat(3, 38px); grid-template-rows: repeat(3, 34px); gap: 4px; justify-content: center; }
|
||||
.direction-pad button { display: grid; place-items: center; padding: 0; border: 1px solid var(--border-strong); background: #fbfcfb; }
|
||||
.direction-pad button svg { width: 18px; height: 18px; }
|
||||
.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; }
|
||||
.coordinates input { width: 100%; }
|
||||
.buttons { display: flex; gap: 10px; margin-top: 12px; }
|
||||
.hint { margin-top: 7px; color: #7b8698; font-size: 12px; }
|
||||
.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; }
|
||||
.hint { margin: 8px 0 0; color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
|
||||
|
||||
.message {
|
||||
position: fixed; z-index: 20; right: 18px; bottom: 16px; display: flex; align-items: center; gap: 8px;
|
||||
max-width: calc(100vw - 36px); min-height: 38px; padding: 0 13px; border: 1px solid #cfe3d4; border-radius: 9px;
|
||||
color: #39784c; background: rgba(245, 251, 246, .97); box-shadow: 0 9px 30px rgba(42, 61, 47, .12);
|
||||
opacity: 0; pointer-events: none; transform: translateY(8px);
|
||||
transition: opacity 160ms ease, transform 180ms var(--ease-out);
|
||||
}
|
||||
.message::before { content: "✓"; display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid currentColor; border-radius: 50%; font-size: 11px; }
|
||||
.message[data-visible="true"] { opacity: 1; transform: translateY(0); }
|
||||
.message[data-kind="error"] { border-color: #efcdc8; color: var(--error); background: rgba(255, 246, 245, .98); }
|
||||
.message[data-kind="error"]::before { content: "!"; font-weight: 700; }
|
||||
|
||||
dialog { width: min(410px, calc(100vw - 40px)); padding: 0; border: 1px solid var(--border); border-radius: 14px; color: inherit; background: var(--surface); box-shadow: 0 22px 70px rgba(26, 38, 29, .22); }
|
||||
dialog::backdrop { background: rgba(28, 34, 29, .38); backdrop-filter: blur(2px); }
|
||||
.modal-content { padding: 23px; }
|
||||
.modal-icon { display: grid; place-items: center; width: 42px; height: 42px; margin-bottom: 15px; border-radius: 11px; background: var(--brand-soft); }
|
||||
.modal-icon img { width: 32px; height: 32px; border-radius: 8px; }
|
||||
.modal-content h2 { margin: 0 0 7px; font-size: 18px; }
|
||||
.modal-content p { margin: 0; color: var(--text-secondary); }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; }
|
||||
.secondary-button { height: 38px; padding: 0 15px; border: 1px solid var(--border-strong); background: var(--surface); font-weight: 600; }
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.position-link:hover { border-color: #cfd9d1; background: #fcfdfc; }
|
||||
.repository-link:hover { color: var(--brand-strong); }
|
||||
.icon-button:hover, .secondary-button:hover, .direction-pad button:hover { background: #f2f5f2; }
|
||||
.preset-grid button:hover { border-color: var(--brand); }
|
||||
button:not(:disabled):active { transform: scale(.97); }
|
||||
.direction-pad button:active, .preset-grid button:active, .shortcut:active { transform: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 590px) {
|
||||
.view { padding-right: 18px; padding-left: 18px; }
|
||||
.service-row { grid-template-columns: 82px 1fr; }
|
||||
.backend-status { grid-column: 2; }
|
||||
.position-tools { grid-template-columns: 1fr; }
|
||||
.preset-section { padding-right: 0; padding-bottom: 14px; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.preset-grid { grid-template-columns: repeat(3, 45px); justify-content: center; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; }
|
||||
button, select, .toggle > span, .toggle > span::after, .position-ball, .message { transition-duration: 0ms !important; }
|
||||
}
|
||||
|
||||
@@ -3,67 +3,138 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>MeloASR 设置</title>
|
||||
<title>TamaASR 设置</title>
|
||||
<link rel="stylesheet" href="settings.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><img src="../assets/logo.png" alt=""><div><h1>MeloASR</h1><span>设置</span></div></header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>语音后端</h2>
|
||||
<div class="row"><label for="backend">后端</label><select id="backend"></select><span id="backend-status" class="status"></span></div>
|
||||
<div id="login-warning" class="warning" hidden>当前后端尚未登录。</div>
|
||||
<button id="open-debug">打开登录/调试页面</button>
|
||||
<main id="settings-view" class="view settings-view">
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<img src="../assets/logo.png" alt="TamaASR">
|
||||
<div><h1>TamaASR</h1><p>Linux 语音输入</p></div>
|
||||
</div>
|
||||
<span id="app-status" class="app-status"><i></i><span>正在连接</span></span>
|
||||
</header>
|
||||
|
||||
<section class="card service-card" aria-labelledby="service-title">
|
||||
<div class="section-heading">
|
||||
<h2 id="service-title">语音服务</h2>
|
||||
<p>选择用于语音识别的网页服务</p>
|
||||
</div>
|
||||
<div class="service-row">
|
||||
<label for="backend">当前服务</label>
|
||||
<div class="select-shell service-select"><select id="backend"></select></div>
|
||||
<span id="backend-status" class="backend-status"><i></i><span></span></span>
|
||||
</div>
|
||||
<div id="login-warning" class="warning" hidden>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2.5a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm0 4v4m0 3h.01"/></svg>
|
||||
<span>当前服务尚未登录,完成登录后才能开始语音输入。</span>
|
||||
</div>
|
||||
<div class="service-actions"><button id="open-debug" class="primary-button" type="button">打开登录与调试</button></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>悬浮球</h2>
|
||||
<label class="switch"><input id="overlay-persistent" type="checkbox"><span>始终显示悬浮球</span></label>
|
||||
<button id="open-position">调整悬浮球位置</button>
|
||||
<section class="card preferences-card" aria-labelledby="preferences-title">
|
||||
<h2 id="preferences-title">输入与启动</h2>
|
||||
<div class="preference-row shortcut-row">
|
||||
<div class="preference-copy"><strong>按住说话</strong><span>按住快捷键开始识别,松开后提交文本</span></div>
|
||||
<button id="shortcut" class="shortcut" type="button"></button>
|
||||
</div>
|
||||
<label class="preference-row toggle-row" for="auto-start">
|
||||
<span class="preference-copy"><strong>开机自动启动</strong><span>登录桌面后自动运行 TamaASR</span></span>
|
||||
<span class="toggle"><input id="auto-start" type="checkbox"><span aria-hidden="true"></span></span>
|
||||
</label>
|
||||
<label class="preference-row toggle-row" for="diagnostic-logging">
|
||||
<span class="preference-copy"><strong>诊断日志</strong><span>仅在排查问题时记录运行信息</span></span>
|
||||
<span class="toggle"><input id="diagnostic-logging" type="checkbox"><span aria-hidden="true"></span></span>
|
||||
</label>
|
||||
</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>
|
||||
<button id="open-position" class="card position-link" type="button">
|
||||
<span class="position-icon"><img src="../assets/logo.png" alt=""></span>
|
||||
<span class="position-link-copy"><strong>悬浮球位置</strong><span id="position-summary">正在读取位置…</span></span>
|
||||
<svg class="chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"/></svg>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<h2>关于</h2>
|
||||
<div class="row"><span>版本</span><span id="version"></span></div>
|
||||
<div class="row"><span>项目仓库</span><button id="repository" class="link"></button></div>
|
||||
</section>
|
||||
<div id="message" role="status"></div>
|
||||
<aside class="trust-notice" aria-label="来源与隐私说明">
|
||||
<p>从官方渠道(GitHub Releases、星火商店等)获取到的本软件,不会获取您的任何个人信息、AI 平台账户信息、对话记录或语音输入记录。</p>
|
||||
<p>如果您是从其他渠道获取,且无法确认来源可信,请立即卸载并更换为官方来源的版本,以防止个人信息泄露。</p>
|
||||
<p class="license-note">本项目遵循 MPL-2.0 许可证开源。</p>
|
||||
</aside>
|
||||
|
||||
<footer class="app-footer">
|
||||
<span>版本 <span id="version"></span></span>
|
||||
<button id="repository" class="repository-link" type="button"><span></span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 5h8v8m0-8L5 15"/></svg></button>
|
||||
</footer>
|
||||
</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>
|
||||
|
||||
<main id="position-view" class="view position-view" hidden>
|
||||
<header class="position-header">
|
||||
<button id="back-settings" class="icon-button" type="button" title="返回设置" aria-label="返回设置">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 4.5-5 5.5 5 5.5"/></svg>
|
||||
</button>
|
||||
<div><h1>悬浮球位置</h1><p>选择它在桌面上的默认位置</p></div>
|
||||
</header>
|
||||
|
||||
<section class="card position-panel">
|
||||
<div class="display-row">
|
||||
<label for="display">显示器</label>
|
||||
<div class="select-shell"><select id="display"></select></div>
|
||||
</div>
|
||||
|
||||
<div id="position-screen" class="position-screen" aria-label="当前显示器的位置预览">
|
||||
<div class="desktop-bar"><span>活动</span><span>TamaASR 桌面</span><span class="desktop-status">● ● ●</span></div>
|
||||
<div class="desktop-window window-back"><i></i><i></i><i></i><span></span><span></span><span></span></div>
|
||||
<div class="desktop-window window-front"><i></i><i></i><i></i><span></span><span></span><span></span></div>
|
||||
<div id="position-ball" class="position-ball"><img src="../assets/logo.png" alt="悬浮球"></div>
|
||||
</div>
|
||||
|
||||
<div class="position-tools">
|
||||
<div class="preset-section">
|
||||
<h2>快速位置</h2>
|
||||
<div class="preset-grid" aria-label="九宫格位置预设">
|
||||
<button data-preset="top-left" type="button" title="左上" aria-label="左上"></button>
|
||||
<button data-preset="top-center" type="button" title="顶部居中" aria-label="顶部居中"></button>
|
||||
<button data-preset="top-right" type="button" title="右上" aria-label="右上"></button>
|
||||
<button data-preset="middle-left" type="button" title="左侧居中" aria-label="左侧居中"></button>
|
||||
<button data-preset="middle-center" type="button" title="屏幕中央" aria-label="屏幕中央"></button>
|
||||
<button data-preset="middle-right" type="button" title="右侧居中" aria-label="右侧居中"></button>
|
||||
<button data-preset="bottom-left" type="button" title="左下" aria-label="左下"></button>
|
||||
<button data-preset="bottom-center" type="button" title="底部居中" aria-label="底部居中"></button>
|
||||
<button data-preset="bottom-right" type="button" title="右下" aria-label="右下"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="precise-section">
|
||||
<h2>精确位置</h2>
|
||||
<div class="precise-controls">
|
||||
<div class="coordinates">
|
||||
<label for="position-x"><span>X</span><input id="position-x" type="number" step="1"></label>
|
||||
<label for="position-y"><span>Y</span><input id="position-y" type="number" step="1"></label>
|
||||
</div>
|
||||
<div class="direction-pad" aria-label="方向微调">
|
||||
<button data-direction="up" type="button" title="上移" aria-label="上移"><svg viewBox="0 0 20 20"><path d="m5 12.5 5-5 5 5"/></svg></button>
|
||||
<button data-direction="left" type="button" title="左移" aria-label="左移"><svg viewBox="0 0 20 20"><path d="m12.5 5-5 5 5 5"/></svg></button>
|
||||
<button data-direction="center" type="button" title="回到屏幕中央" aria-label="回到屏幕中央"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="5.5"/><circle cx="10" cy="10" r="1.5"/><path d="M10 2v2.5M10 15.5V18M2 10h2.5M15.5 10H18"/></svg></button>
|
||||
<button data-direction="right" type="button" title="右移" aria-label="右移"><svg viewBox="0 0 20 20"><path d="m7.5 5 5 5-5 5"/></svg></button>
|
||||
<button data-direction="down" type="button" title="下移" aria-label="下移"><svg viewBox="0 0 20 20"><path d="m5 7.5 5 5 5-5"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">方向键移动 1 像素,按住 Shift 移动 10 像素</p>
|
||||
</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>
|
||||
|
||||
<div id="message" class="message" role="status" aria-live="polite"></div>
|
||||
|
||||
<dialog id="login-notice">
|
||||
<div class="modal-content">
|
||||
<h2 id="login-notice-title">登录后端</h2>
|
||||
<div class="modal-icon" aria-hidden="true"><img src="../assets/logo.png" alt=""></div>
|
||||
<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>
|
||||
<button id="login-notice-cancel" type="button" class="secondary-button">取消</button>
|
||||
<button id="login-notice-continue" type="button" class="primary-button">打开登录页面</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -10,7 +10,6 @@ interface SettingsViewState {
|
||||
sessionState: 'idle' | 'starting' | 'recording' | 'stopping';
|
||||
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 } }>;
|
||||
@@ -18,33 +17,70 @@ interface SettingsViewState {
|
||||
repositoryUrl: string | null;
|
||||
}
|
||||
|
||||
const OVERLAY_SIZE = 88;
|
||||
const PRESET_MARGIN = 32;
|
||||
const PRESET_LABELS: Record<string, string> = {
|
||||
'top-left': '左上角',
|
||||
'top-center': '顶部居中',
|
||||
'top-right': '右上角',
|
||||
'middle-left': '左侧居中',
|
||||
'middle-center': '屏幕中央',
|
||||
'middle-right': '右侧居中',
|
||||
'bottom-left': '左下角',
|
||||
'bottom-center': '底部居中',
|
||||
'bottom-right': '右下角'
|
||||
};
|
||||
|
||||
const backend = document.querySelector<HTMLSelectElement>('#backend')!;
|
||||
const backendStatus = document.querySelector<HTMLElement>('#backend-status')!;
|
||||
const appStatus = document.querySelector<HTMLElement>('#app-status')!;
|
||||
const loginWarning = document.querySelector<HTMLElement>('#login-warning')!;
|
||||
const display = document.querySelector<HTMLSelectElement>('#display')!;
|
||||
const x = document.querySelector<HTMLInputElement>('#position-x')!;
|
||||
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 repositoryLabel = repository.querySelector<HTMLElement>('span')!;
|
||||
const message = document.querySelector<HTMLElement>('#message')!;
|
||||
const settingsView = document.querySelector<HTMLElement>('body > main')!;
|
||||
const settingsView = document.querySelector<HTMLElement>('#settings-view')!;
|
||||
const positionView = document.querySelector<HTMLElement>('#position-view')!;
|
||||
const positionScreen = document.querySelector<HTMLElement>('#position-screen')!;
|
||||
const positionBall = document.querySelector<HTMLElement>('#position-ball')!;
|
||||
const positionSummary = document.querySelector<HTMLElement>('#position-summary')!;
|
||||
const loginNotice = document.querySelector<HTMLDialogElement>('#login-notice')!;
|
||||
const loginNoticeTitle = document.querySelector<HTMLElement>('#login-notice-title')!;
|
||||
const presetButtons = [...document.querySelectorAll<HTMLButtonElement>('[data-preset]')];
|
||||
let state: SettingsViewState;
|
||||
let capturingShortcut = false;
|
||||
let positionMode = false;
|
||||
let messageTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function statusText(status: BackendRuntimeStatus): string {
|
||||
if (status.ready) return '已就绪';
|
||||
if (status.ready || status.login === 'logged-in') return '已登录';
|
||||
if (status.login === 'logged-out') return '需要登录';
|
||||
return status.detail || '正在加载';
|
||||
return '加载中';
|
||||
}
|
||||
|
||||
function renderServiceStatus(status: BackendRuntimeStatus): void {
|
||||
const backendStatusText = backendStatus.querySelector<HTMLElement>('span')!;
|
||||
backendStatusText.textContent = statusText(status);
|
||||
backendStatus.dataset.state = status.ready ? 'ready'
|
||||
: status.login === 'logged-out' ? 'warning' : 'loading';
|
||||
|
||||
const appStatusText = appStatus.querySelector<HTMLElement>('span')!;
|
||||
if (status.ready) {
|
||||
appStatusText.textContent = '服务已就绪';
|
||||
appStatus.dataset.state = 'ready';
|
||||
} else if (status.login === 'logged-out') {
|
||||
appStatusText.textContent = '需要登录';
|
||||
appStatus.dataset.state = 'warning';
|
||||
} else {
|
||||
appStatusText.textContent = '正在连接';
|
||||
appStatus.dataset.state = 'loading';
|
||||
}
|
||||
}
|
||||
|
||||
function render(next: SettingsViewState): void {
|
||||
@@ -52,43 +88,95 @@ function render(next: SettingsViewState): void {
|
||||
backend.replaceChildren(...next.backends.map((item) => new Option(item.label, item.id)));
|
||||
backend.value = next.backendId;
|
||||
const active = next.backends.find((item) => item.id === next.backendId)!;
|
||||
backendStatus.textContent = statusText(active.status);
|
||||
backendStatus.dataset.state = active.status.ready ? 'ready'
|
||||
: active.status.login === 'logged-out' ? 'warning' : 'loading';
|
||||
renderServiceStatus(active.status);
|
||||
loginWarning.hidden = active.status.login !== 'logged-out';
|
||||
|
||||
const previousDisplay = display.value;
|
||||
display.replaceChildren(...next.displays.map((item) => new Option(item.label, item.id)));
|
||||
const containing = next.displays.find((item) => {
|
||||
const area = item.workArea;
|
||||
return next.overlayPosition.x >= area.x && next.overlayPosition.x < area.x + area.width &&
|
||||
next.overlayPosition.y >= area.y && next.overlayPosition.y < area.y + area.height;
|
||||
});
|
||||
const containing = next.displays.find((item) => containsPosition(item.workArea, next.overlayPosition));
|
||||
display.value = next.displays.some((item) => item.id === previousDisplay)
|
||||
? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? '');
|
||||
x.value = String(next.overlayPosition.x);
|
||||
y.value = String(next.overlayPosition.y);
|
||||
renderPositionPreview(next);
|
||||
renderPositionSummary(next, containing);
|
||||
|
||||
shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut);
|
||||
shortcut.dataset.capturing = String(capturingShortcut);
|
||||
shortcut.setAttribute('aria-pressed', String(capturingShortcut));
|
||||
autoStart.checked = next.autoStart;
|
||||
overlayPersistent.checked = next.overlayPersistent;
|
||||
diagnosticLogging.checked = next.diagnosticLogging;
|
||||
version.textContent = next.version;
|
||||
repository.textContent = next.repositoryUrl ?? '尚未配置';
|
||||
repositoryLabel.textContent = next.repositoryUrl ? '项目仓库' : '仓库未配置';
|
||||
repository.disabled = !next.repositoryUrl;
|
||||
}
|
||||
|
||||
function containsPosition(area: SettingsViewState['displays'][number]['workArea'], point: { x: number; y: number }): boolean {
|
||||
return point.x >= area.x && point.x < area.x + area.width && point.y >= area.y && point.y < area.y + area.height;
|
||||
}
|
||||
|
||||
function selectedDisplay(next: SettingsViewState): SettingsViewState['displays'][number] | undefined {
|
||||
return next.displays.find((item) => item.id === display.value) ?? next.displays[0];
|
||||
}
|
||||
|
||||
function presetPoint(area: SettingsViewState['displays'][number]['workArea'], preset: string): { x: number; y: number } {
|
||||
const [vertical, horizontal] = preset.split('-');
|
||||
const pointX = horizontal === 'left' ? area.x + PRESET_MARGIN
|
||||
: horizontal === 'right' ? area.x + area.width - OVERLAY_SIZE - PRESET_MARGIN
|
||||
: area.x + (area.width - OVERLAY_SIZE) / 2;
|
||||
const pointY = vertical === 'top' ? area.y + PRESET_MARGIN
|
||||
: vertical === 'bottom' ? area.y + area.height - OVERLAY_SIZE - PRESET_MARGIN
|
||||
: area.y + (area.height - OVERLAY_SIZE) / 2;
|
||||
return { x: Math.round(pointX), y: Math.round(pointY) };
|
||||
}
|
||||
|
||||
function matchingPreset(next: SettingsViewState, displayInfo = selectedDisplay(next)): string | null {
|
||||
if (!displayInfo) return null;
|
||||
for (const button of presetButtons) {
|
||||
const preset = button.dataset.preset!;
|
||||
const point = presetPoint(displayInfo.workArea, preset);
|
||||
if (point.x === next.overlayPosition.x && point.y === next.overlayPosition.y) return preset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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}%`;
|
||||
const displayInfo = selectedDisplay(next);
|
||||
if (!displayInfo) return;
|
||||
const area = displayInfo.workArea;
|
||||
const centerX = next.overlayPosition.x - area.x + OVERLAY_SIZE / 2;
|
||||
const centerY = next.overlayPosition.y - area.y + OVERLAY_SIZE / 2;
|
||||
const px = Math.max(OVERLAY_SIZE / 2, Math.min(area.width - OVERLAY_SIZE / 2, centerX));
|
||||
const py = Math.max(OVERLAY_SIZE / 2, Math.min(area.height - OVERLAY_SIZE / 2, centerY));
|
||||
positionBall.style.left = `clamp(25px, ${(px / area.width) * 100}%, calc(100% - 25px))`;
|
||||
positionBall.style.top = `clamp(25px, ${(py / area.height) * 100}%, calc(100% - 25px))`;
|
||||
|
||||
const preset = matchingPreset(next, displayInfo);
|
||||
presetButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.preset === preset)));
|
||||
}
|
||||
|
||||
function renderPositionSummary(
|
||||
next: SettingsViewState,
|
||||
containing = next.displays.find((item) => containsPosition(item.workArea, next.overlayPosition))
|
||||
): void {
|
||||
const displayInfo = containing ?? selectedDisplay(next);
|
||||
if (!displayInfo) {
|
||||
positionSummary.textContent = '未检测到显示器';
|
||||
return;
|
||||
}
|
||||
const preset = matchingPreset(next, displayInfo);
|
||||
const screenName = displayInfo.label.split(' · ')[0].split('(')[0];
|
||||
positionSummary.textContent = preset
|
||||
? `${screenName} · ${PRESET_LABELS[preset]}`
|
||||
: `${screenName} · X ${next.overlayPosition.x},Y ${next.overlayPosition.y}`;
|
||||
}
|
||||
|
||||
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')
|
||||
.replaceAll('+', ' + ');
|
||||
}
|
||||
|
||||
function formatShortcut(event: KeyboardEvent): string | null {
|
||||
@@ -106,32 +194,47 @@ function formatShortcut(event: KeyboardEvent): string | null {
|
||||
return [...modifiers, key].join('+');
|
||||
}
|
||||
|
||||
async function apply(action: () => Promise<SettingsViewState>, confirmation = '已保存'): Promise<void> {
|
||||
function showMessage(text: string, kind: 'success' | 'error' = 'success'): void {
|
||||
if (messageTimer) clearTimeout(messageTimer);
|
||||
message.textContent = text;
|
||||
message.dataset.kind = kind;
|
||||
message.dataset.visible = 'true';
|
||||
messageTimer = setTimeout(() => {
|
||||
message.dataset.visible = 'false';
|
||||
messageTimer = null;
|
||||
}, kind === 'error' ? 4200 : 2200);
|
||||
}
|
||||
|
||||
async function apply(action: () => Promise<SettingsViewState>, confirmation = '设置已保存'): Promise<void> {
|
||||
try {
|
||||
render(await action());
|
||||
message.textContent = confirmation;
|
||||
showMessage(confirmation);
|
||||
} catch (error) {
|
||||
message.textContent = (error as Error).message;
|
||||
showMessage((error as Error).message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPosition(): Promise<void> {
|
||||
await apply(() => window.meloSettings.setPosition({ x: Number(x.value), y: Number(y.value) }, display.value));
|
||||
await apply(() => window.meloSettings.setPosition({ x: Number(x.value), y: Number(y.value) }, display.value), '位置已保存');
|
||||
}
|
||||
|
||||
backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value)));
|
||||
backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value), '语音服务已切换'));
|
||||
display.addEventListener('change', () => {
|
||||
renderPositionPreview(state);
|
||||
renderPositionSummary(state);
|
||||
});
|
||||
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';
|
||||
settingsView.hidden = true;
|
||||
positionView.hidden = false;
|
||||
void window.meloSettings.beginPositioning();
|
||||
renderPositionPreview(state);
|
||||
});
|
||||
document.querySelector('#back-settings')!.addEventListener('click', () => {
|
||||
positionMode = false;
|
||||
positionView.style.display = 'none';
|
||||
settingsView.style.display = 'grid';
|
||||
positionView.hidden = true;
|
||||
settingsView.hidden = false;
|
||||
void window.meloSettings.endPositioning();
|
||||
});
|
||||
document.querySelector('#login-notice-cancel')!.addEventListener('click', () => loginNotice.close());
|
||||
@@ -139,7 +242,7 @@ document.querySelector('#login-notice-continue')!.addEventListener('click', () =
|
||||
loginNotice.close();
|
||||
void window.meloSettings.openDebug(true);
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
|
||||
presetButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => void apply(
|
||||
() => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存'
|
||||
));
|
||||
@@ -150,12 +253,11 @@ async function nudge(direction: string): Promise<void> {
|
||||
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;
|
||||
if (direction === 'left') next.x -= 1;
|
||||
if (direction === 'right') next.x += 1;
|
||||
if (direction === 'up') next.y -= 1;
|
||||
if (direction === 'down') next.y += 1;
|
||||
x.value = String(next.x);
|
||||
y.value = String(next.y);
|
||||
await applyPosition();
|
||||
@@ -194,14 +296,17 @@ document.addEventListener('keydown', (event) => {
|
||||
shortcut.addEventListener('click', () => {
|
||||
capturingShortcut = true;
|
||||
shortcut.textContent = '请按下新快捷键…';
|
||||
shortcut.dataset.capturing = 'true';
|
||||
shortcut.setAttribute('aria-pressed', 'true');
|
||||
shortcut.focus();
|
||||
});
|
||||
autoStart.addEventListener('change', () => void apply(() => window.meloSettings.setAutoStart(autoStart.checked)));
|
||||
overlayPersistent.addEventListener('change', () => void apply(
|
||||
() => window.meloSettings.setOverlayPersistent(overlayPersistent.checked)
|
||||
autoStart.addEventListener('change', () => void apply(
|
||||
() => window.meloSettings.setAutoStart(autoStart.checked),
|
||||
autoStart.checked ? '已开启开机自动启动' : '已关闭开机自动启动'
|
||||
));
|
||||
diagnosticLogging.addEventListener('change', () => void apply(
|
||||
() => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked)
|
||||
() => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked),
|
||||
diagnosticLogging.checked ? '已开启诊断日志' : '已关闭诊断日志'
|
||||
));
|
||||
repository.addEventListener('click', () => void window.meloSettings.openRepository());
|
||||
window.meloSettings.onState(render);
|
||||
@@ -212,4 +317,4 @@ window.meloSettings.onLoginNotice((backendLabel) => {
|
||||
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) => showMessage(error.message, 'error'));
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface AppSettings {
|
||||
backendId: string;
|
||||
shortcut: string;
|
||||
autoStart: boolean;
|
||||
overlayPersistent: boolean;
|
||||
diagnosticLogging: boolean;
|
||||
overlayPosition: Point | null;
|
||||
loginNoticeShown: Record<string, boolean>;
|
||||
@@ -43,7 +42,6 @@ export interface SettingsViewState {
|
||||
sessionState: SessionState;
|
||||
shortcut: string;
|
||||
autoStart: boolean;
|
||||
overlayPersistent: boolean;
|
||||
diagnosticLogging: boolean;
|
||||
overlayPosition: Point;
|
||||
displays: DisplayInfo[];
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
import { JsonLineDecoder, defaultSocketPath } from '../src/main/fcitx-bridge';
|
||||
|
||||
test('Socket 路径只使用当前用户运行时目录', () => {
|
||||
assert.equal(defaultSocketPath({ XDG_RUNTIME_DIR: '/run/user/1000' }), '/run/user/1000/meloasr/fcitx5.sock');
|
||||
assert.equal(defaultSocketPath({ XDG_RUNTIME_DIR: '/run/user/1000' }), '/run/user/1000/tama-asr/fcitx5.sock');
|
||||
assert.throws(() => defaultSocketPath({}), /XDG_RUNTIME_DIR/);
|
||||
});
|
||||
|
||||
|
||||