This commit is contained in:
2026-07-31 16:56:33 +08:00
401 changed files with 137458 additions and 0 deletions

19
src/mtran/.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
build/
install/
log/
.tools/
.runtime-build/
.build/
dependencies/tools/*
!dependencies/tools/.gitkeep
vendor/mtranserver/**/node_modules/
vendor/mtranserver/dist/
vendor/mtranserver/release/
__pycache__/
*.pyc
runtime/bin/mtranserver
runtime/config/
runtime/logs/
models/**
!models/
!models/.gitkeep

83
src/mtran/CMakeLists.txt Normal file
View File

@@ -0,0 +1,83 @@
cmake_minimum_required(VERSION 3.8)
project(mtran)
set(prepared_runtime_files
runtime/bin/mtranserver
runtime/config/records.json
models/en_zh-Hans/model.enzh.intgemm.alphas.bin
models/en_zh-Hans/lex.50.50.enzh.s2t.bin
models/en_zh-Hans/srcvocab.enzh.spm
models/en_zh-Hans/trgvocab.enzh.spm
)
foreach(prepared_file IN LISTS prepared_runtime_files)
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${prepared_file}")
message(FATAL_ERROR
"Missing preparation artifact: ${prepared_file}\n"
"Run ./dependencies/auto_install.sh from the mtran package root."
)
endif()
endforeach()
find_package(ament_cmake REQUIRED)
find_package(CURL REQUIRED)
find_package(nlohmann_json REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"srv/TranslateEnglishToChinese.srv"
)
rosidl_get_typesupport_target(cpp_typesupport_target
${PROJECT_NAME} rosidl_typesupport_cpp)
add_library(mtran_core
src/http_translation_client.cpp
src/translator_node.cpp
)
target_compile_features(mtran_core PUBLIC cxx_std_17)
target_include_directories(mtran_core PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
target_link_libraries(mtran_core
CURL::libcurl
nlohmann_json::nlohmann_json
"${cpp_typesupport_target}"
)
ament_target_dependencies(mtran_core rclcpp)
add_executable(mtran_bridge_node src/main.cpp)
target_compile_features(mtran_bridge_node PUBLIC cxx_std_17)
target_link_libraries(mtran_bridge_node mtran_core)
ament_target_dependencies(mtran_bridge_node rclcpp)
install(TARGETS mtran_core mtran_bridge_node
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION lib/${PROJECT_NAME}
)
install(DIRECTORY include/ DESTINATION include)
install(DIRECTORY launch config DESTINATION share/${PROJECT_NAME})
install(PROGRAMS runtime/bin/mtranserver DESTINATION lib/${PROJECT_NAME})
install(DIRECTORY models/en_zh-Hans DESTINATION share/${PROJECT_NAME}/models)
install(FILES runtime/config/records.json DESTINATION share/${PROJECT_NAME}/mtranserver_config)
ament_export_dependencies(rosidl_default_runtime)
if(BUILD_TESTING)
find_package(ament_cmake_gtest REQUIRED)
ament_add_gtest(test_http_translation_client
test/test_http_translation_client.cpp
)
target_link_libraries(test_http_translation_client mtran_core)
ament_add_gtest(test_translator_node
test/test_translator_node.cpp
)
ament_target_dependencies(test_translator_node rclcpp)
target_link_libraries(test_translator_node mtran_core)
target_link_libraries(test_translator_node "${cpp_typesupport_target}")
endif()
ament_package()

327
src/mtran/README.md Normal file
View File

@@ -0,0 +1,327 @@
# mtran ROS2 英译中服务
`mtran` 是一个 ROS2 Humble C++ 功能包。它提供同步服务
`/translate_en_to_zh`,请求中放入一句英语,响应中返回简体中文译文。
```text
ROS2 Service 请求
|
v
mtran_bridge_node (C++ / libcurl)
|
v
常驻 MTranServer sidecar (Bergamot WASM / en_zh-Hans 模型)
```
MTranServer 随 `ros2 launch` 启动并保持常驻。模型只在启动阶段预热,不会在每次
Service 调用时临时启动或重新加载。本包不提供话题接口、批量翻译、多语言选择、
HTML 翻译或自动语言检测。
## 1. 环境前提
- Ubuntu 22.04
- ROS2 Humble且由使用者提前安装
- CPU 架构为 `x86_64``aarch64`
- 首次准备阶段可访问 apt、npm registry 和 Mozilla 模型站点
- 建议预留约 1.5 GB 临时构建空间
`dependencies/auto_install.sh` 只安装非 ROS 依赖、构建 MTranServer、下载并校验
英译中模型。**脚本不会安装 ROS2也不会运行 `colcon build`。** ROS2 和 colcon
构建由工作区使用者负责。
## 2. 放入 ROS2 工作区
目标目录必须是:
```text
smart-healthcare-2026/src/mtran
```
在当前项目中,功能包已经位于以下目录:
```bash
cd ~/smart-healthcare-2026/src/mtran
```
复制完成后,当前目录中应直接存在 `package.xml``CMakeLists.txt`,不能再套一层
`src/mtran`
## 3. 准备依赖、sidecar 和模型
先加载日常终端环境,再执行:
```bash
source ~/.bashrc
cd ~/smart-healthcare-2026/src/mtran/dependencies
./auto_install.sh
```
脚本会请求 `sudo` 安装非 ROS apt 包,在 `dependencies/tools` 安装固定版本 Bun
`vendor/mtranserver` 构建当前 CPU 可运行的 sidecar并只准备
`en -> zh-Hans` 模型。成功结尾类似:
```text
Preparation complete.
Next: cd .../smart-healthcare-2026 && colcon build --packages-select mtran
Then: source install/setup.bash && ros2 launch mtran translator.launch.py
```
## 4. 检查准备结果
返回 `smart-healthcare-2026/src/mtran` 执行:
```bash
cd ..
file runtime/bin/mtranserver
test -s runtime/config/records.json
ls -lh models/en_zh-Hans
python3 dependencies/verify_runtime_data.py \
--records runtime/config/records.json \
--model-dir models/en_zh-Hans
```
应存在以下生成物:
```text
runtime/bin/mtranserver
runtime/config/records.json
models/en_zh-Hans/model.enzh.intgemm.alphas.bin
models/en_zh-Hans/lex.50.50.enzh.s2t.bin
models/en_zh-Hans/srcvocab.enzh.spm
models/en_zh-Hans/trgvocab.enzh.spm
```
校验器会根据 `records.json` 中的 `decompressedHash``decompressedSize` 验证四个
模型文件。校验失败时不要继续构建,重新运行准备脚本。
## 5. 构建 ROS2 包
从工作区根目录 `smart-healthcare-2026` 执行,而不是从包目录执行:
```bash
source ~/.bashrc
PYTHONNOUSERSITE=1 colcon build --packages-select mtran
```
成功时应看到:
```text
Finished <<< mtran
Summary: 1 package finished
```
CMake 不会联网、安装依赖或调用 Bun。它只检查第 4 节的准备产物,然后把 sidecar、
模型和 `records.json` 复制到 ROS2 install space。
## 6. 启动
`smart-healthcare-2026` 执行:
```bash
source ~/.bashrc
source install/setup.bash
PYTHONNOUSERSITE=1 ros2 launch mtran translator.launch.py
```
launch 同时启动 MTranServer 和 `mtran_bridge_node`。节点会等待 sidecar 健康检查成功,
再执行一次内部英译中预热;只有预热完成后才发布 `/translate_en_to_zh`。日志中应出现:
```text
MTranServer is healthy and the en-to-zh-Hans model is warm
```
## 7. 调用服务
保留启动终端,在第二个终端中从 `smart-healthcare-2026` 执行:
```bash
source ~/.bashrc
source install/setup.bash
ros2 service call /translate_en_to_zh \
mtran/srv/TranslateEnglishToChinese \
"{text: 'The robot has completed the inspection.'}"
```
成功响应类似:
```text
mtran.srv.TranslateEnglishToChinese_Response(
success=True,
translation='机器人已完成检查。',
error=''
)
```
## 8. Service 字段和错误码
接口定义:
```srv
string text
---
bool success
string translation
string error
```
- `text`:非空英语普通文本,最多 512 个 UTF-8 字符。
- `success`:翻译成功时为 `true`
- `translation`:成功时为简体中文;失败时始终为空。
- `error`:成功时为空,失败时为下列稳定错误码之一。
| 错误码 | 含义 |
| --- | --- |
| `INVALID_INPUT` | 输入为空或仅含空白。 |
| `INPUT_TOO_LONG` | 输入超过 512 个字符。 |
| `NOT_READY` | sidecar 未就绪、连接中断或模型正在重新预热。 |
| `TIMEOUT` | 单次翻译超过 30 秒。 |
| `UPSTREAM_ERROR` | HTTP 传输或 MTranServer 状态异常。 |
| `INVALID_RESPONSE` | sidecar 返回的 JSON 缺少字符串 `result`。 |
节点使用单线程 executor同一时刻串行处理请求不考虑多句批处理。
## 9. 常驻预热和离线运行
launch 固定向 sidecar 设置:
```text
MT_HOST=127.0.0.1
MT_PORT=8989
MT_ENABLE_UI=false
MT_OFFLINE=true
MT_CHECK_UPDATE=false
MT_WORKER_IDLE_TIMEOUT=86400
MT_CACHE_SIZE=100
```
准备和 `colcon build` 完成后,运行阶段不需要网络。模型 worker 的空闲时间为 24 小时,
桥接节点每 12 小时发送一次内部保温翻译。sidecar 异常退出时 launch 会在 2 秒后重启;
恢复期间服务返回 `NOT_READY`,健康检查和重新预热成功后自动恢复。
## 10. x86_64 和 ARM64
C++ 源码和模型数据不区分 CPU 架构。`auto_install.sh` 在运行机器上本机构建 sidecar
- `uname -m``x86_64` 时生成 x86-64 ELF。
- `uname -m``aarch64` 时生成 ARM64 ELF。
推荐把同一个包目录复制到 ARM64 Ubuntu 22.04 目标机后,在目标机执行准备脚本和
`colcon build`。本包不提供 x86_64 主机到 aarch64 的交叉构建流程。已经下载的
`records.json` 和模型可以跨架构复制,但 `runtime/bin/mtranserver` 必须与目标机架构
匹配;脚本检测到不匹配时会重新构建。
## 11. 重复安装和清理
准备脚本可重复执行。它会复用以下有效产物:
- 版本正确的工作区 Bun
- 架构正确的 sidecar
- 哈希和大小正确的四个模型文件
只重新构建 sidecar`smart-healthcare-2026/src/mtran/dependencies` 执行:
```bash
rm -f ../runtime/bin/mtranserver
rm -rf ../.build/mtranserver
./auto_install.sh
```
完全重新准备,从同一目录执行:
```bash
rm -rf tools ../.build ../runtime/config ../models/en_zh-Hans
rm -f ../runtime/bin/mtranserver
./auto_install.sh
```
清理 ROS2 构建结果,从 `smart-healthcare-2026` 执行:
```bash
rm -rf build/mtran install/mtran log
colcon build --packages-select mtran
```
## 12. 故障排查
### 提示缺少 ROS2 Humble
脚本不会安装 ROS2。确认 `/opt/ros/humble` 存在,并安装错误信息列出的
`ros-humble-*` 包。然后重新执行准备脚本。
### apt 安装失败
检查 Ubuntu 软件源、代理、DNS 和 `sudo` 权限。apt 成功后可直接重复运行脚本,
已完成的本地步骤会被复用。
### Bun 或 npm registry 失败
检查到 `https://registry.npmjs.org/` 的网络连接。工具安装位置是
`dependencies/tools`;删除该目录后重跑脚本可强制重装 Bun 1.3.14。
### 模型缺失或哈希不匹配
从包根运行第 4 节的 `verify_runtime_data.py`。确认 Mozilla records 与附件 CDN 可访问,
然后删除 `runtime/config``models/en_zh-Hans` 并重跑准备脚本。脚本只会把完整校验
后的临时下载移动到最终位置。
### 端口 8989 被占用
检查占用者:
```bash
ss -ltnp | grep ':8989'
```
停止冲突进程,或为 launch 同时覆盖 `port`,并确保没有其他节点仍使用旧端口:
```bash
ros2 launch mtran translator.launch.py port:=8990
```
### sidecar 重启后暂时返回 `NOT_READY`
这是恢复阶段的预期行为。等待日志再次出现模型已预热信息后重试;不要为每次请求
手工启动新的 sidecar。
### 找不到 `/translate_en_to_zh`
服务只在健康检查和首次预热成功后创建。先查看 launch 终端日志,再执行:
```bash
ros2 service list | grep translate_en_to_zh
ros2 service type /translate_en_to_zh
```
还要确认调用终端已经 source `/opt/ros/humble/setup.bash` 和当前工作区
`install/setup.bash`
### Python 用户目录包冲突
若 colcon 或 launch 报告用户目录中的 `setuptools``packaging` 版本冲突,在命令前
保留 `PYTHONNOUSERSITE=1`,使 ROS2 Humble 使用 Ubuntu 系统 Python 包。
## 13. 维护者目录参考
```text
mtran/
├── CMakeLists.txt ROS2 构建、准备检查和安装映射
├── package.xml 包元数据和 ROS 依赖
├── dependencies/
│ ├── auto_install.sh 唯一联网准备入口
│ ├── dependencies.txt 完整依赖和下载来源清单
│ ├── verify_runtime_data.py records/模型完整性校验
│ └── tools/ 工作区 Bun 和缓存(生成)
├── vendor/mtranserver/ 固定提交 f5672a9 的上游源码
├── .build/mtranserver/ sidecar 构建目录(生成)
├── runtime/bin/mtranserver 本机 sidecar生成
├── runtime/config/records.json Mozilla 模型索引(生成)
├── models/en_zh-Hans/ 四个英译中模型文件(生成)
├── include/mtran/ C++ 头文件
├── src/ C++ 实现
├── srv/ ROS2 Service 定义
├── launch/ sidecar 与桥接节点启动文件
├── config/ ROS2 参数
└── test/ C++、shell 和 Python 测试
```
依赖版本、来源和生成/提交边界以 `dependencies/dependencies.txt` 为准。

View File

@@ -0,0 +1,8 @@
mtran_bridge_node:
ros__parameters:
server_url: "http://127.0.0.1:8989"
request_timeout_ms: 30000
startup_timeout_ms: 60000
health_retry_ms: 500
keep_warm_interval_s: 43200
max_input_characters: 512

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
package_root=$(cd "${script_dir}/.." && pwd -P)
vendor_source="${package_root}/vendor/mtranserver"
build_dir="${package_root}/.build/mtranserver"
runtime_data_build_dir="${package_root}/.build/runtime-data"
runtime_bin_dir="${package_root}/runtime/bin"
runtime_config_dir="${package_root}/runtime/config"
model_root="${package_root}/models"
model_dir="${model_root}/en_zh-Hans"
tools_dir="${script_dir}/tools"
verify_script="${script_dir}/verify_runtime_data.py"
bun_bin=""
BUN_VERSION=1.3.14
os_release_file=${MTRAN_OS_RELEASE_FILE:-/etc/os-release}
machine=${MTRAN_MACHINE:-$(uname -m)}
die()
{
echo "error: $*" >&2
exit 1
}
validate_host()
{
[[ -r "${os_release_file}" ]] || die "cannot read OS release file: ${os_release_file}"
# shellcheck disable=SC1090
source "${os_release_file}"
[[ "${ID:-}" == "ubuntu" && "${VERSION_ID:-}" == "22.04" ]] || \
die "Ubuntu 22.04 is required."
case "${machine}" in
x86_64 | aarch64) ;;
*) die "supported CPU architecture must be x86_64 or aarch64; found ${machine}." ;;
esac
[[ "${ROS_DISTRO:-}" == "humble" ]] || \
die "ROS2 Humble environment must be sourced before running this script."
[[ -f "${vendor_source}/package.json" && -f "${vendor_source}/bun.lock" ]] || \
die "vendored MTranServer source is missing under vendor/mtranserver."
[[ -f "${verify_script}" ]] || die "runtime verifier is missing: ${verify_script}"
}
validate_ros_packages()
{
local required_packages=(
ros-humble-ament-cmake
ros-humble-ament-cmake-gtest
ros-humble-rclcpp
ros-humble-rosidl-default-generators
ros-humble-rosidl-default-runtime
ros-humble-launch
ros-humble-launch-ros
ros-humble-ament-index-python
)
local missing=()
local package
for package in "${required_packages[@]}"; do
if ! dpkg-query -W -f='${Status}' "${package}" 2>/dev/null | grep -Fq 'install ok installed'; then
missing+=("${package}")
fi
done
if ((${#missing[@]})); then
printf 'error: missing ROS2 Humble packages (install these separately):\n' >&2
printf ' %s\n' "${missing[@]}" >&2
exit 1
fi
}
install_system_dependencies()
{
local apt_packages=(
build-essential
cmake
pkg-config
curl
ca-certificates
file
rsync
npm
libcurl4-openssl-dev
nlohmann-json3-dev
)
local missing=()
local package
for package in "${apt_packages[@]}"; do
local available=false
case "${package}" in
build-essential)
command -v c++ >/dev/null 2>&1 && command -v make >/dev/null 2>&1 && available=true
;;
ca-certificates)
[[ -r /etc/ssl/certs/ca-certificates.crt ]] && available=true
;;
libcurl4-openssl-dev | nlohmann-json3-dev)
if dpkg-query -W -f='${Status}' "${package}" 2>/dev/null | grep -Fq 'install ok installed'; then
available=true
fi
;;
*)
command -v "${package}" >/dev/null 2>&1 && available=true
;;
esac
if [[ "${available}" != true ]]; then
missing+=("${package}")
fi
done
if ((${#missing[@]} == 0)); then
echo "Reusing installed non-ROS system dependencies."
return
fi
printf 'Installing missing non-ROS system dependencies: %s\n' "${missing[*]}"
sudo apt-get update
sudo apt-get install -y "${missing[@]}"
}
prepare_bun()
{
bun_bin="${tools_dir}/node_modules/.bin/bun"
export BUN_INSTALL_CACHE_DIR="${tools_dir}/bun-cache"
export npm_config_cache="${tools_dir}/npm-cache"
export ELECTRON_SKIP_BINARY_DOWNLOAD=1
if [[ ! -x "${bun_bin}" || "$("${bun_bin}" --version 2>/dev/null || true)" != "${BUN_VERSION}" ]]; then
mkdir -p "${tools_dir}"
npm install --prefix "${tools_dir}" --no-save --no-package-lock "bun@${BUN_VERSION}"
fi
[[ -x "${bun_bin}" ]] || die "workspace-local Bun installation failed."
[[ "$("${bun_bin}" --version)" == "${BUN_VERSION}" ]] || \
die "workspace-local Bun version must be ${BUN_VERSION}."
export PATH="$(dirname "${bun_bin}"):${PATH}"
}
sidecar_architecture_matches()
{
local executable=$1
local description
[[ -x "${executable}" ]] || return 1
description=$(file -b "${executable}" 2>/dev/null) || return 1
case "${machine}" in
x86_64) [[ "${description}" == *"x86-64"* || "${description}" == *"x86_64"* ]] ;;
aarch64) [[ "${description}" == *"aarch64"* || "${description}" == *"ARM64"* ]] ;;
esac
}
build_sidecar_if_needed()
{
local bun_bin=$1
local sidecar="${runtime_bin_dir}/mtranserver"
local staged_sidecar="${runtime_bin_dir}/.mtranserver.tmp.$$"
if sidecar_architecture_matches "${sidecar}"; then
echo "Reusing native MTranServer sidecar: ${sidecar}"
return
fi
mkdir -p "${build_dir}" "${runtime_bin_dir}"
rsync -a --delete \
--exclude '.git/' \
--exclude 'node_modules/' \
--exclude 'dist/' \
--exclude 'release/' \
--exclude '.cache/' \
"${vendor_source}/" "${build_dir}/"
(
cd "${build_dir}"
"${bun_bin}" install --frozen-lockfile
"${bun_bin}" run build
)
[[ -x "${build_dir}/dist/mtranserver" ]] || die "MTranServer build did not produce dist/mtranserver."
install -m 0755 "${build_dir}/dist/mtranserver" "${staged_sidecar}"
if ! sidecar_architecture_matches "${staged_sidecar}"; then
rm -f "${staged_sidecar}"
die "built MTranServer architecture does not match ${machine}."
fi
mv -f "${staged_sidecar}" "${sidecar}"
echo "Staged native MTranServer sidecar: ${sidecar}"
}
runtime_data_is_valid()
{
python3 "${verify_script}" \
--records "${runtime_config_dir}/records.json" \
--model-dir "${model_dir}" >/dev/null 2>&1
}
install_verified_runtime_data()
{
local staged_records="${runtime_data_build_dir}/config/records.json"
local staged_models="${runtime_data_build_dir}/models/en_zh-Hans"
local next_models="${model_root}/.en_zh-Hans.next.$$"
local old_models="${model_root}/.en_zh-Hans.old.$$"
local next_records="${runtime_config_dir}/.records.json.next.$$"
python3 "${verify_script}" --records "${staged_records}" --model-dir "${staged_models}"
mkdir -p "${model_root}" "${runtime_config_dir}"
rm -rf "${next_models}" "${old_models}"
mkdir -p "${next_models}"
cp -a "${staged_models}/." "${next_models}/"
if [[ -e "${model_dir}" ]]; then
mv "${model_dir}" "${old_models}"
fi
if ! mv "${next_models}" "${model_dir}"; then
[[ -e "${old_models}" ]] && mv "${old_models}" "${model_dir}"
die "failed to install verified model directory."
fi
rm -rf "${old_models}"
install -m 0644 "${staged_records}" "${next_records}"
mv -f "${next_records}" "${runtime_config_dir}/records.json"
}
prepare_runtime_data_if_needed()
{
local sidecar="${runtime_bin_dir}/mtranserver"
if runtime_data_is_valid; then
echo "Reusing verified en -> zh-Hans runtime data."
return
fi
rm -rf "${runtime_data_build_dir}"
mkdir -p "${runtime_data_build_dir}/config" "${runtime_data_build_dir}/models"
MT_HOST=127.0.0.1 \
MT_PORT=0 \
MT_ENABLE_UI=false \
MT_OFFLINE=false \
MT_CHECK_UPDATE=false \
MT_LOG_LEVEL=info \
MT_CONFIG_DIR="${runtime_data_build_dir}/config" \
MT_MODEL_DIR="${runtime_data_build_dir}/models" \
"${sidecar}" --download en-zh
install_verified_runtime_data
runtime_data_is_valid || die "installed runtime data failed final verification."
echo "Staged verified en -> zh-Hans runtime data."
}
main()
{
validate_host
validate_ros_packages
install_system_dependencies
prepare_bun
build_sidecar_if_needed "${bun_bin}"
prepare_runtime_data_if_needed
local workspace_root
workspace_root=$(cd "${package_root}/../.." && pwd -P)
printf 'Preparation complete.\n'
printf 'Next: cd %q && colcon build --packages-select mtran\n' "${workspace_root}"
printf 'Then: source install/setup.bash && ros2 launch mtran translator.launch.py\n'
}
main "$@"

View File

@@ -0,0 +1,73 @@
MTran ROS2 package dependency manifest
=====================================
This file is documentation. It is not a package-manager lock file.
User-provided prerequisites
---------------------------
- Ubuntu 22.04 on x86_64 or aarch64.
- ROS2 Humble, installed and maintained by the user.
- The ROS2 Humble environment must be sourced before running auto_install.sh
or the package shell tests.
- Python 3 from Ubuntu/ROS2, used for runtime-data integrity checks.
- Required ROS packages:
- ament_cmake
- ament_cmake_gtest
- rclcpp
- rosidl_default_generators
- rosidl_default_runtime
- launch
- launch_ros
- ament_index_python
- auto_install.sh does not install ROS2.
- colcon build is run by the user from the smart-healthcare-2026 workspace.
Installed by dependencies/auto_install.sh with apt
---------------------------------------------------
- build-essential
- cmake
- pkg-config
- curl
- ca-certificates
- file
- rsync
- npm
- libcurl4-openssl-dev
- nlohmann-json3-dev
Workspace-local build tool
--------------------------
- Bun 1.3.14.
- Installed with npm from https://registry.npmjs.org/ below
dependencies/tools/node_modules.
- Bun package and Electron download caches remain below dependencies/tools.
- Bun is used only to build the native MTranServer sidecar during preparation.
Included source
---------------
- MTranServer 4.0.33.
- Source repository: https://github.com/xxnuo/MTranServer.git
- Source commit: f5672a9
- License: Apache-2.0; see vendor/mtranserver/LICENSE.
- Committed source location: vendor/mtranserver.
- Generated build location: .build/mtranserver.
Downloaded runtime data
-----------------------
- Mozilla translations-models-v2 records endpoint:
https://firefox.settings.services.mozilla.com/v1/buckets/main-preview/collections/translations-models-v2/records
- Mozilla attachment CDN base URL:
https://firefox-settings-attachments.cdn.mozilla.net
- Exact translation direction: en -> zh-Hans.
- Required latest-version file roles: model, lex, srcvocab, trgvocab.
- records.json is staged at runtime/config/records.json.
- Decompressed model files are staged below models/en_zh-Hans.
- Every model file is verified with the SHA-256 decompressedHash from
records.json; decompressedSize is also checked when present.
Generated runtime artifacts
---------------------------
- runtime/bin/mtranserver is built for the host architecture.
- runtime/config/records.json is architecture-independent.
- models/en_zh-Hans is architecture-independent.
- Generated artifacts are installed by CMake but are not committed to Git.

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Dict, Iterable, Tuple
REQUIRED_ROLES = ("model", "lex", "srcvocab", "trgvocab")
class RuntimeDataError(RuntimeError):
pass
def _version_key(version: object) -> Tuple[Tuple[int, object], ...]:
parts = re.split(r"(\d+)", str(version))
return tuple((0, int(part)) if part.isdigit() else (1, part) for part in parts)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _load_records(records_path: Path) -> Iterable[dict]:
try:
document = json.loads(records_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise RuntimeDataError(f"invalid records.json: {error}") from error
records = document.get("data") if isinstance(document, dict) else None
if not isinstance(records, list):
raise RuntimeDataError("invalid records.json: data must be a list")
return records
def verify_runtime_data(records_path: Path, model_dir: Path) -> Dict[str, Path]:
candidates = [
record
for record in _load_records(records_path)
if isinstance(record, dict)
and record.get("sourceLanguage") == "en"
and record.get("targetLanguage") == "zh-Hans"
]
selected = {}
for role in REQUIRED_ROLES:
role_records = [record for record in candidates if record.get("fileType") == role]
if not role_records:
raise RuntimeDataError(f"missing required record role: {role}")
selected[role] = max(role_records, key=lambda record: _version_key(record.get("version", "")))
verified = {}
for role, record in selected.items():
attachment = record.get("attachment")
filename = attachment.get("filename") if isinstance(attachment, dict) else None
if not isinstance(filename, str) or not filename:
raise RuntimeDataError(f"invalid attachment filename for role: {role}")
decompressed_name = filename[:-4] if filename.endswith(".zst") else filename
if Path(decompressed_name).name != decompressed_name:
raise RuntimeDataError(f"unsafe attachment filename for role: {role}")
model_path = model_dir / decompressed_name
if not model_path.is_file():
raise RuntimeDataError(f"missing model file for role {role}: {decompressed_name}")
expected_size = record.get("decompressedSize")
if expected_size is not None and model_path.stat().st_size != expected_size:
raise RuntimeDataError(f"size mismatch for role {role}: {decompressed_name}")
expected_hash = record.get("decompressedHash")
if not isinstance(expected_hash, str) or not expected_hash:
raise RuntimeDataError(f"missing decompressedHash for role: {role}")
if _sha256(model_path) != expected_hash.lower():
raise RuntimeDataError(f"hash mismatch for role {role}: {decompressed_name}")
verified[role] = model_path
expected_names = {path.name for path in verified.values()}
actual_names = {entry.name for entry in model_dir.iterdir()}
extras = sorted(actual_names - expected_names)
if extras:
raise RuntimeDataError(f"unexpected model files: {', '.join(extras)}")
return verified
def main() -> int:
parser = argparse.ArgumentParser(description="Verify the fixed en to zh-Hans runtime data")
parser.add_argument("--records", required=True, type=Path)
parser.add_argument("--model-dir", required=True, type=Path)
args = parser.parse_args()
try:
verified = verify_runtime_data(args.records, args.model_dir)
except RuntimeDataError as error:
print(f"runtime data verification failed: {error}", file=sys.stderr)
return 1
for role in REQUIRED_ROLES:
print(f"verified {role}: {verified[role]}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,595 @@
# MTranServer ROS2 English-to-Chinese Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a ROS2 Humble C++ service under `migrate_ws` that accepts one English string, translates it through a launch-managed persistent MTranServer, and returns Simplified Chinese.
**Architecture:** A C++ `mtran_bridge_node` exposes `/translate_en_to_zh` and sends fixed `en -> zh-Hans` JSON requests to a loopback MTranServer sidecar. ROS2 launch owns both processes; the bridge performs startup health checking, model warm-up, serialized request handling, and periodic keep-warm calls.
**Tech Stack:** ROS2 Humble, Ubuntu 22.04, C++17, ament_cmake, rosidl, libcurl, nlohmann/json, GoogleTest, Python ROS2 launch, Bun-compiled MTranServer.
## Global Constraints
- Every new or generated migration file must remain under `/home/hikos/MTranServer/migrate_ws`.
- The service name is `/translate_en_to_zh`.
- The service request contains only `string text`.
- The response contains `bool success`, `string translation`, and `string error`.
- Translation direction is fixed to `en -> zh-Hans`; HTML, language detection, topics, batching, and retries are excluded.
- Input is limited to 512 UTF-8 code points.
- Translation execution is serialized with `rclcpp::executors::SingleThreadedExecutor`.
- The current machine uses a native MTranServer build; ARM64 is documented but not built in this iteration.
- MTranServer uses a 86,400-second idle timeout and the bridge sends a keep-warm translation every 43,200 seconds.
- Runtime translation must work offline after the executable and `en_zh-Hans` model are staged.
---
## File Map
- `migrate_ws/.gitignore`: excludes colcon output, local tools, runtime binary, temporary runtime build tree, and model data.
- `migrate_ws/src/mtran_ros2/package.xml`: ROS2 and system dependency metadata.
- `migrate_ws/src/mtran_ros2/CMakeLists.txt`: service generation, C++ targets, install rules, and tests.
- `migrate_ws/src/mtran_ros2/srv/TranslateEnglishToChinese.srv`: public ROS2 interface.
- `migrate_ws/src/mtran_ros2/include/mtran_ros2/http_translation_client.hpp`: transport-independent client contract and libcurl client declaration.
- `migrate_ws/src/mtran_ros2/src/http_translation_client.cpp`: fixed HTTP payload, libcurl transport, JSON parsing, and error mapping.
- `migrate_ws/src/mtran_ros2/include/mtran_ros2/translator_node.hpp`: node state and service declaration.
- `migrate_ws/src/mtran_ros2/src/translator_node.cpp`: readiness state machine, validation, service callback, recovery, and keep-warm.
- `migrate_ws/src/mtran_ros2/src/main.cpp`: ROS2 initialization and single-threaded spin.
- `migrate_ws/src/mtran_ros2/config/translator.yaml`: production parameter defaults.
- `migrate_ws/src/mtran_ros2/launch/translator.launch.py`: sidecar and bridge lifecycle.
- `migrate_ws/src/mtran_ros2/scripts/build_mtranserver.sh`: native sidecar staging entirely below `migrate_ws`.
- `migrate_ws/src/mtran_ros2/test/test_http_translation_client.cpp`: injected-transport unit tests.
- `migrate_ws/src/mtran_ros2/test/test_translator_node.cpp`: fake-client ROS2 service tests.
- `migrate_ws/README.md`: dependency, local build, launch, smoke-test, offline-model, and ARM64 instructions.
---
### Task 1: Create the ROS2 Package and Service Contract
**Files:**
- Create: `migrate_ws/.gitignore`
- Create: `migrate_ws/src/mtran_ros2/package.xml`
- Create: `migrate_ws/src/mtran_ros2/CMakeLists.txt`
- Create: `migrate_ws/src/mtran_ros2/srv/TranslateEnglishToChinese.srv`
**Interfaces:**
- Consumes: ROS2 Humble `ament_cmake` and `rosidl_default_generators`.
- Produces: `mtran_ros2::srv::TranslateEnglishToChinese` with request field `text` and response fields `success`, `translation`, and `error`.
- [ ] **Step 1: Record generated-file exclusions**
```gitignore
build/
install/
log/
.tools/
.runtime-build/
runtime/bin/mtranserver
models/**
!models/.gitkeep
```
- [ ] **Step 2: Write the service definition**
```srv
string text
---
bool success
string translation
string error
```
- [ ] **Step 3: Add the minimal package manifest**
Declare `ament_cmake`, `rosidl_default_generators`, `rclcpp`, `rosidl_default_runtime`, `launch`, `launch_ros`, libcurl, nlohmann/json, and test dependencies. Add membership in `rosidl_interface_packages`.
- [ ] **Step 4: Add interface generation to CMake**
```cmake
cmake_minimum_required(VERSION 3.8)
project(mtran_ros2)
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"srv/TranslateEnglishToChinese.srv"
)
ament_export_dependencies(rosidl_default_runtime)
ament_package()
```
- [ ] **Step 5: Build to verify service generation**
Run:
```bash
source /opt/ros/humble/setup.bash
cd /home/hikos/MTranServer/migrate_ws
colcon build --packages-select mtran_ros2
```
Expected: exit code 0 and generated service headers below `build/mtran_ros2/rosidl_generator_cpp`.
- [ ] **Step 6: Inspect the generated interface**
Run:
```bash
source /home/hikos/MTranServer/migrate_ws/install/setup.bash
ros2 interface show mtran_ros2/srv/TranslateEnglishToChinese
```
Expected: the exact four fields from the `.srv` definition.
- [ ] **Step 7: Commit the package contract**
```bash
git add migrate_ws/.gitignore migrate_ws/src/mtran_ros2/package.xml \
migrate_ws/src/mtran_ros2/CMakeLists.txt \
migrate_ws/src/mtran_ros2/srv/TranslateEnglishToChinese.srv
git commit -m "feat: add ROS2 translation service contract"
```
---
### Task 2: Implement the Fixed-Direction HTTP Client
**Files:**
- Create: `migrate_ws/src/mtran_ros2/include/mtran_ros2/http_translation_client.hpp`
- Create: `migrate_ws/src/mtran_ros2/src/http_translation_client.cpp`
- Create: `migrate_ws/src/mtran_ros2/test/test_http_translation_client.cpp`
- Modify: `migrate_ws/src/mtran_ros2/CMakeLists.txt`
- Modify: `migrate_ws/src/mtran_ros2/package.xml`
**Interfaces:**
- Consumes: `server_url` and `request_timeout_ms` configuration.
- Produces: `TranslationClient::health()` and `TranslationClient::translate(const std::string&)`.
Use these exact public types:
```cpp
enum class ClientError {
kNone,
kUnavailable,
kTimeout,
kUpstream,
kInvalidResponse,
};
struct TranslationResult {
bool success;
std::string translation;
ClientError error;
};
struct HttpResponse {
int transport_code;
long status_code;
std::string body;
};
using HttpExecutor = std::function<HttpResponse(
const std::string & method,
const std::string & url,
const std::string & body,
long timeout_ms)>;
class TranslationClient {
public:
virtual ~TranslationClient() = default;
virtual bool health() = 0;
virtual TranslationResult translate(const std::string & text) = 0;
};
class HttpTranslationClient final : public TranslationClient {
public:
HttpTranslationClient(
std::string server_url,
long timeout_ms,
HttpExecutor executor = {});
bool health() override;
TranslationResult translate(const std::string & text) override;
};
```
- [ ] **Step 1: Write failing payload and parsing tests**
Inject an `HttpExecutor` lambda that captures method, URL, body, and timeout. Assert that `translate("Hello")` sends `POST`, targets `/translate`, uses the configured timeout, and emits:
```json
{"from":"en","html":false,"text":"Hello","to":"zh-Hans"}
```
Also assert that HTTP 200 with `{"result":"你好"}` returns `success=true`, `translation="你好"`, and `ClientError::kNone`.
- [ ] **Step 2: Write failing error-mapping tests**
Add separate tests for:
```cpp
HttpResponse{CURLE_COULDNT_CONNECT, 0, ""} // kUnavailable
HttpResponse{CURLE_OPERATION_TIMEDOUT, 0, ""} // kTimeout
HttpResponse{CURLE_OK, 500, "failure"} // kUpstream
HttpResponse{CURLE_OK, 200, "not-json"} // kInvalidResponse
HttpResponse{CURLE_OK, 200, R"({"result":7})"} // kInvalidResponse
```
For every failure, assert `success=false` and `translation.empty()`.
- [ ] **Step 3: Run the client test to verify it fails**
Run:
```bash
source /opt/ros/humble/setup.bash
cd /home/hikos/MTranServer/migrate_ws
colcon test --packages-select mtran_ros2 --ctest-args -R test_http_translation_client --output-on-failure
```
Expected: test target or referenced client types are missing.
- [ ] **Step 4: Implement `HttpTranslationClient`**
Use nlohmann/json to serialize the fixed request and parse `result`. Normalize `server_url` by removing trailing slashes. The default executor initializes libcurl once with `std::call_once`, writes response bytes through a callback, sets `CURLOPT_CONNECTTIMEOUT_MS`, `CURLOPT_TIMEOUT_MS`, JSON content type, and `CURLOPT_NOSIGNAL=1L`.
Map libcurl connection and name-resolution failures to `kUnavailable`, `CURLE_OPERATION_TIMEDOUT` to `kTimeout`, other transport/non-200 failures to `kUpstream`, and response-shape failures to `kInvalidResponse`.
- [ ] **Step 5: Add the client library and test targets**
```cmake
find_package(CURL REQUIRED)
find_package(nlohmann_json REQUIRED)
find_package(rclcpp REQUIRED)
add_library(mtran_core
src/http_translation_client.cpp
)
target_compile_features(mtran_core PUBLIC cxx_std_17)
target_include_directories(mtran_core PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>)
target_link_libraries(mtran_core PUBLIC
CURL::libcurl
nlohmann_json::nlohmann_json)
```
Under `BUILD_TESTING`, add `ament_cmake_gtest`, create `test_http_translation_client`, and link it to `mtran_core`.
- [ ] **Step 6: Run the client tests**
Run:
```bash
source /opt/ros/humble/setup.bash
cd /home/hikos/MTranServer/migrate_ws
colcon build --packages-select mtran_ros2 --cmake-args -DBUILD_TESTING=ON
colcon test --packages-select mtran_ros2 --ctest-args -R test_http_translation_client --output-on-failure
colcon test-result --verbose
```
Expected: all client tests pass.
- [ ] **Step 7: Commit the HTTP client**
```bash
git add migrate_ws/src/mtran_ros2
git commit -m "feat: add fixed-direction MTran HTTP client"
```
---
### Task 3: Implement the ROS2 Service Node and Readiness State Machine
**Files:**
- Create: `migrate_ws/src/mtran_ros2/include/mtran_ros2/translator_node.hpp`
- Create: `migrate_ws/src/mtran_ros2/src/translator_node.cpp`
- Create: `migrate_ws/src/mtran_ros2/src/main.cpp`
- Create: `migrate_ws/src/mtran_ros2/test/test_translator_node.cpp`
- Modify: `migrate_ws/src/mtran_ros2/CMakeLists.txt`
**Interfaces:**
- Consumes: `std::shared_ptr<TranslationClient>` and generated `TranslateEnglishToChinese` service type.
- Produces: `/translate_en_to_zh`, startup/recovery health polling, warm-up, validation, and keep-warm behavior.
Use this constructor boundary:
```cpp
class TranslatorNode : public rclcpp::Node {
public:
explicit TranslatorNode(
const rclcpp::NodeOptions & options = rclcpp::NodeOptions(),
std::shared_ptr<TranslationClient> client = nullptr);
};
```
- [ ] **Step 1: Write the fake client and failing validation tests**
Create a thread-safe fake implementing `TranslationClient`. Configure node parameter overrides `health_retry_ms=1`, `keep_warm_interval_s=3600`, and `max_input_characters=512`. Spin the node until `/translate_en_to_zh` is available.
Assert:
```text
"" and " " -> INVALID_INPUT
512 ASCII characters -> client called and success returned
513 ASCII characters -> INPUT_TOO_LONG and client not called
```
- [ ] **Step 2: Write failing response-mapping tests**
For each fake client result, assert these exact service responses:
```text
kNone -> success=true, translation set, error empty
kUnavailable -> success=false, translation empty, NOT_READY
kTimeout -> success=false, translation empty, TIMEOUT
kUpstream -> success=false, translation empty, UPSTREAM_ERROR
kInvalidResponse -> success=false, translation empty, INVALID_RESPONSE
```
- [ ] **Step 3: Write failing readiness and recovery tests**
Verify that the service is not created while `health()` is false. Then switch health to true and make the warm-up translation succeed; verify the service becomes available. Simulate `kUnavailable` during a service call, verify `NOT_READY`, restore the fake, and verify health polling plus warm-up return the node to successful service operation.
- [ ] **Step 4: Run node tests to verify failure**
Run:
```bash
source /opt/ros/humble/setup.bash
cd /home/hikos/MTranServer/migrate_ws
colcon test --packages-select mtran_ros2 --ctest-args -R test_translator_node --output-on-failure
```
Expected: `TranslatorNode` and executable are missing.
- [ ] **Step 5: Implement startup, service, and recovery**
Declare the six parameters from the design. When no client is injected, construct `HttpTranslationClient`. Start a health timer immediately. A successful health call followed by `translate("Hello.")` sets `ready_=true`, creates the service on first startup, cancels health polling, and starts keep-warm scheduling.
Count UTF-8 characters by counting bytes that are not continuation bytes (`(byte & 0xC0) != 0x80`). Validate before checking readiness. Serialize all callbacks through the single-threaded executor.
If a service or keep-warm call returns `kUnavailable`, set `ready_=false` and restart health polling. Do not destroy the already-advertised service after initial startup; requests during recovery return `NOT_READY`.
- [ ] **Step 6: Implement `main.cpp`**
```cpp
int main(int argc, char ** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<mtran_ros2::TranslatorNode>();
rclcpp::executors::SingleThreadedExecutor executor;
executor.add_node(node);
executor.spin();
rclcpp::shutdown();
return 0;
}
```
- [ ] **Step 7: Add node targets and generated typesupport linkage**
Add `translator_node.cpp` to `mtran_core`, obtain the generated C++ typesupport target with `rosidl_get_typesupport_target`, and link it. Add `mtran_bridge_node` from `main.cpp`, link `mtran_core`, and install the library, executable, and headers.
- [ ] **Step 8: Run node and client tests**
Run:
```bash
source /opt/ros/humble/setup.bash
cd /home/hikos/MTranServer/migrate_ws
colcon build --packages-select mtran_ros2 --cmake-args -DBUILD_TESTING=ON
colcon test --packages-select mtran_ros2 --event-handlers console_direct+
colcon test-result --verbose
```
Expected: all tests pass with no failed test cases.
- [ ] **Step 9: Commit the ROS2 node**
```bash
git add migrate_ws/src/mtran_ros2
git commit -m "feat: expose persistent English to Chinese ROS2 service"
```
---
### Task 4: Add Native Runtime Staging and ROS2 Launch
**Files:**
- Create: `migrate_ws/src/mtran_ros2/config/translator.yaml`
- Create: `migrate_ws/src/mtran_ros2/launch/translator.launch.py`
- Create: `migrate_ws/src/mtran_ros2/scripts/build_mtranserver.sh`
- Create: `migrate_ws/models/.gitkeep`
- Modify: `migrate_ws/src/mtran_ros2/CMakeLists.txt`
**Interfaces:**
- Consumes: repository MTranServer source, local npm/node, launch arguments, staged executable, and staged model directory.
- Produces: native `migrate_ws/runtime/bin/mtranserver` and one launch entry point that owns sidecar and bridge lifecycles.
- [ ] **Step 1: Write the production parameter file**
```yaml
mtran_bridge_node:
ros__parameters:
server_url: "http://127.0.0.1:8989"
request_timeout_ms: 30000
startup_timeout_ms: 60000
health_retry_ms: 500
keep_warm_interval_s: 43200
max_input_characters: 512
```
- [ ] **Step 2: Write the runtime staging script**
The script resolves the repository root from its own path, installs Bun locally with npm under `migrate_ws/.tools` when no usable local Bun exists, copies the MTranServer source into `migrate_ws/.runtime-build/mtranserver` while excluding `.git`, `migrate_ws`, `node_modules`, and generated outputs, runs `bun install --frozen-lockfile`, runs `bun run build --single`, and installs the executable at `migrate_ws/runtime/bin/mtranserver` with mode 0755.
The script must use `set -euo pipefail`, must not write to `/usr/local`, and must not modify the repository's root `dist/` directory.
- [ ] **Step 3: Run the staging script**
Run:
```bash
cd /home/hikos/MTranServer
./migrate_ws/src/mtran_ros2/scripts/build_mtranserver.sh
file migrate_ws/runtime/bin/mtranserver
```
Expected on the current machine: an executable x86-64 ELF MTranServer binary.
- [ ] **Step 4: Write the launch file**
Declare arguments `server_executable`, `model_dir`, `config_dir`, `params_file`, `host`, and `port`. Defaults resolve from the source workspace for local development and may be overridden for installed deployment. Start MTranServer with `ExecuteProcess` using:
```text
MT_HOST=<host>
MT_PORT=<port>
MT_ENABLE_UI=false
MT_OFFLINE=true
MT_CHECK_UPDATE=false
MT_CONFIG_DIR=<config_dir>
MT_MODEL_DIR=<model_dir>
MT_WORKER_IDLE_TIMEOUT=86400
MT_CACHE_SIZE=100
```
Enable sidecar respawn with a 2-second delay. Start `mtran_bridge_node` with the parameter file and a `server_url` override derived from host and port.
- [ ] **Step 5: Add launch syntax test and install rules**
Run:
```bash
python3 -m py_compile migrate_ws/src/mtran_ros2/launch/translator.launch.py
```
Expected: exit code 0. Install `launch/`, `config/`, and `scripts/` into the package share/lib destinations through CMake.
- [ ] **Step 6: Verify launch description without starting translation**
Run:
```bash
source /opt/ros/humble/setup.bash
cd /home/hikos/MTranServer/migrate_ws
colcon build --packages-select mtran_ros2
source install/setup.bash
ros2 launch mtran_ros2 translator.launch.py --show-args
```
Expected: all six launch arguments are listed with concrete defaults.
- [ ] **Step 7: Commit runtime and launch support**
```bash
git add migrate_ws/.gitignore migrate_ws/models/.gitkeep \
migrate_ws/src/mtran_ros2/config \
migrate_ws/src/mtran_ros2/launch \
migrate_ws/src/mtran_ros2/scripts \
migrate_ws/src/mtran_ros2/CMakeLists.txt
git commit -m "feat: launch persistent MTran sidecar"
```
---
### Task 5: Document, Stage the Model, and Verify End to End
**Files:**
- Create: `migrate_ws/README.md`
- Modify only if verification finds a defect: files below `migrate_ws/src/mtran_ros2/`
**Interfaces:**
- Consumes: built native sidecar, ROS2 package, and `en_zh-Hans` model files.
- Produces: reproducible local and ARM64 instructions plus evidence that automated and available runtime checks pass.
- [ ] **Step 1: Write the README**
Document:
- Architecture and fixed service contract
- Ubuntu 22.04/ROS2 Humble dependencies
- Native runtime staging command
- How to pre-download/copy only `en_zh-Hans` model files
- `colcon build`, launch, and service-call commands
- Error-code table
- Offline operation and model residency behavior
- x64 native build behavior
- ARM64 native build and `bun-linux-arm64` cross-target commands
- How to override executable and model paths at launch
- [ ] **Step 2: Run formatting and static build checks**
Run:
```bash
source /opt/ros/humble/setup.bash
cd /home/hikos/MTranServer/migrate_ws
colcon build --packages-select mtran_ros2 --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBUILD_TESTING=ON
colcon test --packages-select mtran_ros2 --event-handlers console_direct+
colcon test-result --verbose
python3 -m py_compile src/mtran_ros2/launch/translator.launch.py
```
Expected: build and tests exit 0; Python compilation produces no output.
- [ ] **Step 3: Check the staged runtime and model prerequisites**
Run:
```bash
test -x /home/hikos/MTranServer/migrate_ws/runtime/bin/mtranserver
find /home/hikos/MTranServer/migrate_ws/models/en_zh-Hans -maxdepth 1 -type f -print
```
Expected: executable check passes and the model directory contains model, vocabulary, and lexical files required by MTranServer.
- [ ] **Step 4: Start launch and execute the smoke test**
Terminal 1:
```bash
source /opt/ros/humble/setup.bash
source /home/hikos/MTranServer/migrate_ws/install/setup.bash
ros2 launch mtran_ros2 translator.launch.py
```
Terminal 2:
```bash
source /opt/ros/humble/setup.bash
source /home/hikos/MTranServer/migrate_ws/install/setup.bash
ros2 service call /translate_en_to_zh \
mtran_ros2/srv/TranslateEnglishToChinese \
"{text: 'The robot has completed the inspection.'}"
```
Expected: `success: true`, non-empty Chinese `translation`, and empty `error`.
- [ ] **Step 5: Verify offline restart**
Restart the launch with `MT_OFFLINE=true` already enforced by the launch file and no network dependency. Repeat the same service request and expect the same response shape without model download attempts.
- [ ] **Step 6: Inspect repository scope**
Run:
```bash
cd /home/hikos/MTranServer
git status --short
git diff --check
find migrate_ws -maxdepth 4 -type f -print | sort
```
Expected: authored migration changes are only under `migrate_ws`; generated runtime, models, tools, and colcon directories are ignored.
- [ ] **Step 7: Commit documentation and final corrections**
```bash
git add migrate_ws/README.md migrate_ws/src/mtran_ros2 migrate_ws/.gitignore migrate_ws/models/.gitkeep
git commit -m "docs: add ROS2 translation deployment guide"
```
- [ ] **Step 8: Run final verification from a clean build directory**
Move the existing generated `build`, `install`, and `log` directories to a temporary directory under `/tmp`, then rerun the complete build and test commands from Step 2. Do not delete user data or model files.
Expected: a clean configuration, build, and test pass.

View File

@@ -0,0 +1,754 @@
# Portable `mtran` ROS2 Package Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn `migrate_ws` into a self-contained ROS2 package named `mtran` that can be copied to `smart_healthcare_2026/src/mtran`, prepared online once, and built and run offline on native `x86_64` or `aarch64` Ubuntu 22.04 systems.
**Architecture:** Keep the existing C++17 bridge and synchronous `/translate_en_to_zh` service, but move the package root from `migrate_ws/src/mtran_ros2` to `migrate_ws` and rename all ROS identities from `mtran_ros2` to `mtran`. A version-pinned MTranServer source tree is vendored under `vendor/mtranserver`; `dependencies/auto_install.sh` stages and builds it under `.build/mtranserver`, downloads only the `en -> zh-Hans` offline data, and places generated artifacts under `runtime/` and `models/`. CMake only validates and installs prepared artifacts, while launch resolves every runtime path from the installed package share/prefix.
**Tech Stack:** ROS2 Humble, `ament_cmake`, C++17, `rclcpp`, `rosidl_default_generators`, libcurl, nlohmann-json, Python ROS2 launch, Bash, Bun 1.3.14, MTranServer 4.0.33 at commit `f5672a9`.
## Global Constraints
- Target OS is Ubuntu 22.04; ROS2 Humble is supplied by the user and is never installed by `auto_install.sh`.
- Native CPU architectures are `x86_64` and `aarch64`; the sidecar is built on the host where the script runs.
- The package and source-directory name are `mtran`; the generated service type is `mtran/srv/TranslateEnglishToChinese`.
- The only runtime service is `/translate_en_to_zh`; it translates one English string to Simplified Chinese.
- The service node and executable remain `mtran_bridge_node`; its existing readiness, validation, error mapping, and single-thread behavior remain intact.
- `auto_install.sh` may use the network during preparation, but runtime and CMake must not download files or require network access.
- `auto_install.sh` must not source a user workspace, install ROS2, or run `colcon build`; the user runs `colcon build --packages-select mtran` from `smart_healthcare_2026`.
- CMake must never invoke Bun, npm, curl, apt, or any other download/build preparation command.
- Vendored source is fixed at MTranServer commit `f5672a9` and build-generated files stay outside `vendor/mtranserver`.
- Generated Bun tools, build staging, sidecar, records, models, colcon outputs, and logs are ignored; vendored source, package source, manifests, scripts, and documentation are committed.
- Do not modify or commit the unrelated repository-root `.vscode/` directory.
## File Map
The implementation changes the current tree as follows:
- Move `migrate_ws/src/mtran_ros2/CMakeLists.txt` to `migrate_ws/CMakeLists.txt`; define package `mtran`, build the existing C++ targets, validate prepared artifacts, and install them into the ROS install space.
- Move `migrate_ws/src/mtran_ros2/package.xml` to `migrate_ws/package.xml`; rename the package and keep only ROS package metadata and system library dependencies.
- Move `migrate_ws/src/mtran_ros2/include/mtran_ros2/*.hpp` to `migrate_ws/include/mtran/*.hpp`; rename namespaces and generated-service includes to `mtran`.
- Move `migrate_ws/src/mtran_ros2/src/*.cpp` to `migrate_ws/src/*.cpp`; update include paths and namespaces without changing service semantics.
- Move `migrate_ws/src/mtran_ros2/srv/TranslateEnglishToChinese.srv` to `migrate_ws/srv/TranslateEnglishToChinese.srv`; preserve its fields exactly.
- Move `migrate_ws/src/mtran_ros2/test/*.cpp` to `migrate_ws/test/*.cpp`; update includes/namespaces and preserve the existing 16 behavioral assertions.
- Move `migrate_ws/src/mtran_ros2/config/translator.yaml` to `migrate_ws/config/translator.yaml`; retain bridge parameters.
- Move `migrate_ws/src/mtran_ros2/launch/translator.launch.py` to `migrate_ws/launch/translator.launch.py`; resolve defaults from installed `mtran` resources only.
- Remove `migrate_ws/src/mtran_ros2/scripts/build_mtranserver.sh`; replace it with `migrate_ws/dependencies/auto_install.sh`, which owns all preparation.
- Create `migrate_ws/dependencies/dependencies.txt` as the human-readable dependency/download manifest.
- Create `migrate_ws/vendor/mtranserver/` from the fixed upstream commit and add provenance/license notes.
- Rewrite `migrate_ws/README.md` for a first-time teammate, with the copy, preparation, build, launch, and service-call path on the first screen.
- Extend `migrate_ws/.gitignore` for `dependencies/tools/`, `.build/`, generated runtime/model data, colcon output, and test caches while allowing required empty directories through `.gitkeep` files where needed.
- Add focused shell/Python tests under `migrate_ws/test/` for package layout, preparation-script validation/idempotence, CMake preparation checks, install mapping, and launch path resolution.
---
### Task 1: Flatten the Package and Rename the ROS Identity
**Files:**
- Move: `migrate_ws/src/mtran_ros2/CMakeLists.txt` -> `migrate_ws/CMakeLists.txt`
- Move: `migrate_ws/src/mtran_ros2/package.xml` -> `migrate_ws/package.xml`
- Move: `migrate_ws/src/mtran_ros2/include/mtran_ros2/http_translation_client.hpp` -> `migrate_ws/include/mtran/http_translation_client.hpp`
- Move: `migrate_ws/src/mtran_ros2/include/mtran_ros2/translator_node.hpp` -> `migrate_ws/include/mtran/translator_node.hpp`
- Move: `migrate_ws/src/mtran_ros2/src/*.cpp` -> `migrate_ws/src/*.cpp`
- Move: `migrate_ws/src/mtran_ros2/srv/TranslateEnglishToChinese.srv` -> `migrate_ws/srv/TranslateEnglishToChinese.srv`
- Move: `migrate_ws/src/mtran_ros2/test/*.cpp` -> `migrate_ws/test/*.cpp`
- Move: `migrate_ws/src/mtran_ros2/config/translator.yaml` -> `migrate_ws/config/translator.yaml`
- Move: `migrate_ws/src/mtran_ros2/launch/translator.launch.py` -> `migrate_ws/launch/translator.launch.py`
- Delete: `migrate_ws/src/mtran_ros2/scripts/build_mtranserver.sh` after its preparation responsibilities are covered by Task 4.
- Modify: `migrate_ws/.gitignore`
- Test: `migrate_ws/test/test_package_identity.sh`
**Interfaces:**
- Consumes: the current `mtran_ros2` package files and the service contract `string text` / `bool success, string translation, string error`.
- Produces: a package rooted directly at `migrate_ws`, named `mtran`, with C++ namespaces/includes and generated service references under `mtran`.
- [ ] **Step 1: Add a failing package-layout test before moving files.**
Create `test/test_package_identity.sh` with these assertions:
```bash
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
test -f "${root}/package.xml"
test -f "${root}/CMakeLists.txt"
test ! -e "${root}/src/mtran_ros2/package.xml"
grep -q '<name>mtran</name>' "${root}/package.xml"
grep -q 'project(mtran)' "${root}/CMakeLists.txt"
grep -q 'mtran/srv/TranslateEnglishToChinese' "${root}/README.md"
! rg -n 'mtran_ros2' "${root}" -g '!docs/superpowers/**' -g '!vendor/**'
```
Run from the package root:
```bash
bash test/test_package_identity.sh
```
Expected: FAIL because the package is still nested and named `mtran_ros2`.
- [ ] **Step 2: Move the package files and remove the extra source nesting.**
From the repository root, use `git mv` for the paths listed above, create the target directories, and leave `migrate_ws` itself as the ROS package root. Preserve the service definition byte-for-byte. Do not move `docs/` or the existing `.gitignore` outside `migrate_ws`.
- [ ] **Step 3: Rename package symbols and generated-service references.**
Replace only package identity references in the moved source and tests:
```text
mtran_ros2:: -> mtran::
"mtran_ros2/ -> "mtran/
project(mtran_ros2) -> project(mtran)
<name>mtran_ros2</name> -> <name>mtran</name>
package="mtran_ros2" -> package="mtran"
```
Keep `/translate_en_to_zh`, `mtran_bridge_node`, `TranslateEnglishToChinese`, parameter names, and error strings unchanged. Update the README service command to:
```bash
ros2 service call /translate_en_to_zh \
mtran/srv/TranslateEnglishToChinese \
"{text: 'Hello.'}"
```
- [ ] **Step 4: Make the identity test pass and run the existing C++ test suite.**
Run:
```bash
bash test/test_package_identity.sh
source /opt/ros/humble/setup.bash
PYTHONNOUSERSITE=1 colcon build --packages-select mtran --cmake-args -DBUILD_TESTING=ON
PYTHONNOUSERSITE=1 colcon test --packages-select mtran --event-handlers console_direct+
PYTHONNOUSERSITE=1 colcon test-result --verbose
```
Expected: the identity test passes, the package configures as `mtran`, and the existing HTTP/client-node tests pass before runtime-resource installation is added. If CMake currently requires generated runtime files, temporarily keep that check out until Task 5; the identity test remains the gate for this task.
- [ ] **Step 5: Commit the package flattening and namespace rename.**
```bash
git add migrate_ws
git commit -m "refactor: rename portable ROS package to mtran"
```
---
### Task 2: Vendor the Fixed MTranServer Source
**Files:**
- Create: `migrate_ws/vendor/mtranserver/**`
- Create: `migrate_ws/vendor/mtranserver/PROVENANCE.md`
- Modify: `migrate_ws/.gitignore`
- Test: `migrate_ws/test/test_vendor_source.sh`
**Interfaces:**
- Consumes: tracked files at repository commit `f5672a9`.
- Produces: a read-only source tree containing the MTranServer 4.0.33 single-binary build inputs, with no dependency on the parent repository during preparation.
- [ ] **Step 1: Add the vendor provenance test.**
Create `test/test_vendor_source.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
vendor="${root}/vendor/mtranserver"
test -f "${vendor}/package.json"
test -f "${vendor}/bun.lock"
test -f "${vendor}/scripts/build.ts"
test ! -e "${vendor}/.git"
test ! -e "${vendor}/dist"
test ! -e "${vendor}/node_modules"
grep -q '"version": "4.0.33"' "${vendor}/package.json"
grep -q 'f5672a9' "${vendor}/PROVENANCE.md"
```
Run `bash test/test_vendor_source.sh`. Expected: FAIL because the vendor directory does not exist.
- [ ] **Step 2: Extract exactly the fixed upstream commit.**
From `/home/hikos/MTranServer`, create the directory and extract only tracked files:
```bash
mkdir -p migrate_ws/vendor/mtranserver
git archive f5672a9 | tar -x -C migrate_ws/vendor/mtranserver
```
Remove any generated `dist/`, `release/`, `node_modules/`, local caches, logs, and repository metadata if present. Do not copy the current `migrate_ws` directory into the vendor tree.
- [ ] **Step 3: Record provenance and make the vendor tree read-only by convention.**
Write `vendor/mtranserver/PROVENANCE.md` containing:
```text
Source repository: https://github.com/xxnuo/MTranServer.git
Source commit: f5672a9
Release: MTranServer 4.0.33
License: Apache-2.0 (see LICENSE)
Build entry point: package.json script "build" -> scripts/build.ts --single
Policy: this tree is vendored input; generated files belong in ../../.build/mtranserver.
```
The implementation must use `git archive` provenance rather than a live parent-repository path. Add `vendor/mtranserver/**/node_modules`, `vendor/mtranserver/dist`, and `vendor/mtranserver/release` to ignore rules only as defensive protection; do not ignore tracked source files.
- [ ] **Step 4: Run the vendor test and verify the source tree is complete.**
Run:
```bash
bash test/test_vendor_source.sh
test ! -e migrate_ws/vendor/mtranserver/.git
grep -Fq '"version": "4.0.33"' migrate_ws/vendor/mtranserver/package.json
```
Expected: the test passes, the vendor tree contains the Bun lockfile, build script, UI/assets inputs, and no generated build output or nested Git metadata.
- [ ] **Step 5: Commit the vendored source.**
```bash
git add migrate_ws/vendor migrate_ws/.gitignore migrate_ws/test/test_vendor_source.sh
git commit -m "build: vendor MTranServer 4.0.33 source"
```
---
### Task 3: Add the Dependency Manifest
**Files:**
- Create: `migrate_ws/dependencies/dependencies.txt`
- Modify: `migrate_ws/.gitignore`
- Test: `migrate_ws/test/test_dependency_manifest.sh`
**Interfaces:**
- Consumes: the fixed environment and upstream dependencies defined in the design specification.
- Produces: a human-readable manifest that distinguishes user-provided prerequisites, script-installed apt packages, workspace tools, vendored source, and generated downloads.
- [ ] **Step 1: Add a manifest coverage test.**
Create `test/test_dependency_manifest.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
manifest="${root}/dependencies/dependencies.txt"
test -f "${manifest}"
for required in \
"Ubuntu 22.04" "ROS2 Humble" "ament_cmake" "rclcpp" \
"build-essential" "libcurl4-openssl-dev" "nlohmann-json3-dev" \
"Bun 1.3.14" "MTranServer 4.0.33" "f5672a9" \
"records.json" "en -> zh-Hans" "decompressedHash"; do
grep -Fq "${required}" "${manifest}"
done
grep -Fq "auto_install.sh does not install ROS2" "${manifest}"
grep -Fq "colcon build is run by the user" "${manifest}"
```
Run `bash test/test_dependency_manifest.sh`. Expected: FAIL because the manifest does not exist.
- [ ] **Step 2: Write the complete manifest.**
Use these sections and values in `dependencies.txt`:
```text
User-provided prerequisites
- Ubuntu 22.04
- ROS2 Humble: ament_cmake, ament_cmake_gtest, rclcpp,
rosidl_default_generators, rosidl_default_runtime, launch, launch_ros
- auto_install.sh does not install ROS2
- colcon build is run by the user
Installed by dependencies/auto_install.sh (apt)
- build-essential, cmake, pkg-config, curl, ca-certificates, rsync,
npm, libcurl4-openssl-dev, nlohmann-json3-dev
Workspace-local tool
- Bun 1.3.14, installed below dependencies/tools with its cache there
Included source
- MTranServer 4.0.33, commit f5672a9, Apache-2.0
Downloaded runtime data
- Mozilla translations-models-v2 records endpoint
- Mozilla attachment CDN base URL
- exact direction en -> zh-Hans
- model, lex, srcvocab, trgvocab files
- decompressedHash SHA-256 verification
```
Include the upstream URL from `git remote get-url origin` and the exact endpoint strings from `src/models/records.ts`.
- [ ] **Step 3: Run the manifest test.**
Run `bash test/test_dependency_manifest.sh`. Expected: PASS, with every dependency category and the distinction between ROS prerequisites and script-installed packages visible to a new user.
- [ ] **Step 4: Commit the manifest.**
```bash
git add migrate_ws/dependencies/dependencies.txt migrate_ws/.gitignore migrate_ws/test/test_dependency_manifest.sh
git commit -m "docs: document portable mtran dependencies"
```
---
### Task 4: Implement and Test `dependencies/auto_install.sh`
**Files:**
- Create: `migrate_ws/dependencies/auto_install.sh`
- Create: `migrate_ws/dependencies/tools/.gitkeep`
- Modify: `migrate_ws/.gitignore`
- Test: `migrate_ws/test/test_auto_install.sh`
**Interfaces:**
- Consumes: `vendor/mtranserver`, the host's Ubuntu/ROS2/CPU state, apt/npm/network, and Mozilla records/model endpoints.
- Produces: executable `runtime/bin/mtranserver`, `runtime/config/records.json`, and exactly four decompressed files below `models/en_zh-Hans/`; it returns non-zero for invalid prerequisites or failed integrity checks.
- [ ] **Step 1: Add shell-level tests with command stubs.**
Create `test/test_auto_install.sh` as a Bash harness that invokes the script with a temporary `PATH`, temporary package root copy, and stub commands. The harness must cover these cases:
```text
1. bash -n succeeds.
2. fake uname -m = ppc64le -> non-zero and message contains x86_64/aarch64.
3. fake /etc/os-release != Ubuntu 22.04 -> non-zero.
4. neither ROS_DISTRO=humble nor /opt/ros/humble -> non-zero and message says ROS2 Humble must be installed.
5. missing vendor/mtranserver -> non-zero before apt/npm/network work.
6. valid fixture artifacts plus fake apt/npm/bun/curl/file/sha256sum -> second run skips valid sidecar, records, and model files and does not run the build/download stubs.
```
The fixture must use a copied package root so tests never write to the real runtime directories. Permit only these explicit test seams in the script: `MTRAN_OS_RELEASE_FILE` defaults to `/etc/os-release`, `MTRAN_MACHINE` defaults to `uname -m`, and `MTRAN_ROS_PREFIX` defaults to `/opt/ros/humble`. Production behavior with no overrides must always inspect the real host. Run:
```bash
bash -n dependencies/auto_install.sh
bash test/test_auto_install.sh
```
Expected: syntax passes; behavioral tests initially FAIL because the script does not exist.
- [ ] **Step 2: Implement path resolution and prerequisite checks.**
At the top of the script use exactly this path relationship:
```bash
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
package_root=$(cd "${script_dir}/.." && pwd -P)
vendor_source="${package_root}/vendor/mtranserver"
build_dir="${package_root}/.build/mtranserver"
runtime_bin_dir="${package_root}/runtime/bin"
```
Use `set -euo pipefail`, validate `${MTRAN_OS_RELEASE_FILE:-/etc/os-release}` for `ID=ubuntu` and `VERSION_ID=22.04`, accept only `${MTRAN_MACHINE:-$(uname -m)}` values `x86_64` and `aarch64`, and require `ROS_DISTRO=humble` or `${MTRAN_ROS_PREFIX:-/opt/ros/humble}`. Check these Debian packages with `dpkg-query -W` without installing them: `ros-humble-ament-cmake`, `ros-humble-ament-cmake-gtest`, `ros-humble-rclcpp`, `ros-humble-rosidl-default-generators`, `ros-humble-rosidl-default-runtime`, `ros-humble-launch`, and `ros-humble-launch-ros`. Print the complete missing-package list and exit non-zero before running apt/npm/network commands.
- [ ] **Step 3: Implement non-ROS apt and workspace-local Bun preparation.**
Install only the manifest's apt packages using `sudo apt-get update` and `sudo apt-get install -y`; never include `ros-humble-*`. Reuse or install Bun 1.3.14 below `dependencies/tools`, set `BUN_INSTALL_CACHE_DIR` below `dependencies/tools`, prepend the local Bun binary to `PATH`, and set `ELECTRON_SKIP_BINARY_DOWNLOAD=1`. Pin the Bun package/version in the command or an explicit local version check so a newer global Bun cannot silently change the build.
- [ ] **Step 4: Implement sidecar staging without touching the vendor tree.**
Require `vendor/mtranserver/package.json` and `vendor/mtranserver/bun.lock`. Copy with `rsync -a --delete` from vendor to `.build/mtranserver`, excluding `.git`, `node_modules`, `dist`, `release`, and local caches. Run in the staging directory:
```bash
"${bun_bin}" install --frozen-lockfile
"${bun_bin}" run build
```
Then atomically install `dist/mtranserver` as mode `0755` at `runtime/bin/mtranserver`. Verify it is executable and its ELF architecture matches `uname -m` (`x86-64` for `x86_64`, `aarch64` for `aarch64`) using `file`; reject a mismatched existing artifact.
- [ ] **Step 5: Implement records/model download and integrity verification.**
Download the records endpoint to a temporary file under `runtime/config`, validate JSON shape before renaming to `records.json`, and use the attachment CDN base URL from the vendored implementation. Parse records structurally with an available JSON tool or a small Bun script under `dependencies/tools`; do not select files by uncontrolled filename globbing. Filter exact `sourceLanguage == en` and `targetLanguage == zh-Hans`, select the newest version per required `fileType`, and require all four roles: `model`, `lex`, `srcvocab`, `trgvocab`.
For every selected attachment, download the `.zst` to a temporary path, decompress it, compute SHA-256 on the decompressed bytes, compare with `decompressedHash` and, when present, `decompressedSize`, then rename the verified decompressed output to its final filename. Remove compressed temporary files only after verification. A valid existing file is reused; a missing or mismatched file is replaced. Never leave a mismatched file at a final path.
- [ ] **Step 6: Print the post-preparation commands and make the tests pass.**
On success print at least:
```bash
workspace_root=$(cd "${package_root}/../.." && pwd -P)
printf 'Preparation complete.\n'
printf 'Next: cd %q && colcon build --packages-select mtran\n' "${workspace_root}"
printf 'Then: source install/setup.bash && ros2 launch mtran translator.launch.py\n'
```
Run:
```bash
bash -n dependencies/auto_install.sh
bash test/test_auto_install.sh
```
Expected: PASS for syntax, all invalid-environment failures, vendor isolation, architecture checks, and idempotent valid-artifact reuse. The real network preparation is deferred to the final integration task.
- [ ] **Step 7: Commit the preparation script.**
```bash
git add migrate_ws/dependencies/auto_install.sh migrate_ws/dependencies/tools/.gitkeep migrate_ws/.gitignore migrate_ws/test/test_auto_install.sh
git commit -m "build: add portable mtran dependency installer"
```
---
### Task 5: Make CMake Validate and Install Prepared Runtime Data
**Files:**
- Modify: `migrate_ws/CMakeLists.txt`
- Modify: `migrate_ws/package.xml`
- Create: `migrate_ws/test/test_cmake_preparation.sh`
- Modify: `migrate_ws/.gitignore`
**Interfaces:**
- Consumes: the renamed C++/service files from Task 1 and generated preparation outputs from Task 4.
- Produces: a package that configures only when preparation outputs exist and installs the sidecar, model, records, launch file, config, headers, executable, and service type under package `mtran`.
- [ ] **Step 1: Add the missing-artifact CMake test.**
Create `test/test_cmake_preparation.sh` that copies the source package to a temporary directory, sources `/opt/ros/humble/setup.bash`, removes `runtime/bin/mtranserver`, `runtime/config/records.json`, and `models/en_zh-Hans/*`, then runs:
```bash
cmake -S "${fixture}" -B "${fixture}/build"
```
Expected: FAIL with output containing `dependencies/auto_install.sh` and naming the missing preparation artifact. The test must not invoke network commands or Bun.
- [ ] **Step 2: Add explicit CMake preparation checks.**
Before target definitions, require these paths and stop with `message(FATAL_ERROR ...)` if absent:
```text
runtime/bin/mtranserver
runtime/config/records.json
models/en_zh-Hans/model.enzh.intgemm.alphas.bin
models/en_zh-Hans/lex.50.50.enzh.s2t.bin
models/en_zh-Hans/srcvocab.enzh.spm
models/en_zh-Hans/trgvocab.enzh.spm
```
The error must say `Run ./dependencies/auto_install.sh from the mtran package root.` Use `${CMAKE_CURRENT_SOURCE_DIR}` only for source inputs and never add a download/custom command.
- [ ] **Step 3: Update the ROS target and install rules.**
Keep the existing `mtran_core` and `mtran_bridge_node` targets and C++17/libcurl/nlohmann-json/rclcpp dependencies, but generate interfaces with `${PROJECT_NAME}` after `project(mtran)`. Install:
```cmake
install(TARGETS mtran_core mtran_bridge_node
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION lib/${PROJECT_NAME})
install(DIRECTORY include/ DESTINATION include)
install(DIRECTORY launch config DESTINATION share/${PROJECT_NAME})
install(PROGRAMS runtime/bin/mtranserver DESTINATION lib/${PROJECT_NAME})
install(DIRECTORY models/en_zh-Hans DESTINATION share/${PROJECT_NAME}/models)
install(FILES runtime/config/records.json DESTINATION share/${PROJECT_NAME}/mtranserver_config)
```
Do not install source-side `runtime/config` or model parent directories wholesale, because the generated data is intentionally mapped into the package install space.
- [ ] **Step 4: Update package metadata and build tests.**
Set `<name>mtran</name>`. Keep `ament_cmake`, `rosidl_default_generators`, `rosidl_default_runtime`, `rclcpp`, `libcurl4-openssl-dev`, `nlohmann-json3-dev`, and `ament_cmake_gtest` declarations valid for the target ROS2 distribution. Add any missing build/runtime dependency needed by `launch/translator.launch.py` (`launch`, `launch_ros`, and `ament_index_python`) as package dependencies.
Run the test fixture with all six prepared paths populated by small fixture files plus an executable sidecar fixture:
```bash
bash test/test_cmake_preparation.sh
source /opt/ros/humble/setup.bash
PYTHONNOUSERSITE=1 colcon build --packages-select mtran --cmake-args -DBUILD_TESTING=ON
```
Expected: the missing-artifact case fails clearly; the prepared fixture configures; a real `colcon build` succeeds and places the sidecar at `install/mtran/lib/mtran/mtranserver`, models under `install/mtran/share/mtran/models/en_zh-Hans`, and records at `install/mtran/share/mtran/mtranserver_config/records.json`.
- [ ] **Step 5: Run the C++ tests after install mapping.**
```bash
PYTHONNOUSERSITE=1 colcon test --packages-select mtran --event-handlers console_direct+
PYTHONNOUSERSITE=1 colcon test-result --verbose
```
Expected: all existing client/node tests pass, and no CMake step attempts to access the network or run Bun.
- [ ] **Step 6: Commit the CMake/install-space implementation.**
```bash
git add migrate_ws/CMakeLists.txt migrate_ws/package.xml migrate_ws/test/test_cmake_preparation.sh migrate_ws/.gitignore
git commit -m "build: install prepared mtran runtime assets"
```
---
### Task 6: Resolve Launch Defaults Exclusively from Install Space
**Files:**
- Modify: `migrate_ws/launch/translator.launch.py`
- Modify: `migrate_ws/package.xml`
- Create: `migrate_ws/test/test_launch_paths.py`
**Interfaces:**
- Consumes: installed package share directory and the install mapping from Task 5.
- Produces: launch defaults for `mtranserver`, `models`, `mtranserver_config`, and `translator.yaml` that remain valid after the source package is copied or moved.
- [ ] **Step 1: Add a failing launch-path test.**
Create `test/test_launch_paths.py` that reads the launch source and asserts:
```python
from pathlib import Path
root = Path(__file__).resolve().parents[1]
source = (root / "launch" / "translator.launch.py").read_text()
assert 'get_package_share_directory("mtran")' in source
assert 'get_package_prefix("mtran")' in source
assert 'get_package_share_directory("mtran_ros2")' not in source
assert "_find_workspace_root" not in source
assert '"mtran"' in source
assert '"mtranserver_config"' in source
assert 'Path.cwd()' not in source
assert "/home/hikos/" not in source
```
Run `python3 test/test_launch_paths.py`. Expected: FAIL because the current launch searches parent directories and names `mtran_ros2`.
- [ ] **Step 2: Rewrite default paths from the installed share directory.**
Use:
```python
from ament_index_python.packages import get_package_prefix, get_package_share_directory
package_prefix = Path(get_package_prefix("mtran"))
package_share = Path(get_package_share_directory("mtran"))
server_default = package_prefix / "lib" / "mtran" / "mtranserver"
model_default = package_share / "models"
config_default = package_share / "mtranserver_config"
params_default = package_share / "config" / "translator.yaml"
```
Declare those values as defaults for `server_executable`, `model_dir`, `config_dir`, and `params_file`. Keep `host` default `127.0.0.1`, `port` default `8989`, the exact offline environment variables, `respawn=True`, and the bridge package/executable names updated to `mtran`/`mtran_bridge_node`. Do not search upward, inspect `src/`, use `Path.cwd()`, or embed an absolute path.
- [ ] **Step 3: Make launch static and functional tests pass.**
Run:
```bash
python3 test/test_launch_paths.py
python3 -m py_compile launch/translator.launch.py
source /opt/ros/humble/setup.bash
PYTHONNOUSERSITE=1 ros2 launch mtran translator.launch.py --show-args
```
Expected: the path test and syntax check pass; `--show-args` lists install-space defaults and no source-workspace path. Use a temporary fake installed share directory in a Python launch test if the local install has not yet been rebuilt.
- [ ] **Step 4: Commit the install-space launch behavior.**
```bash
git add migrate_ws/launch/translator.launch.py migrate_ws/package.xml migrate_ws/test/test_launch_paths.py
git commit -m "fix: resolve mtran launch assets from install space"
```
---
### Task 7: Rewrite the README for First-Time Onboarding
**Files:**
- Modify: `migrate_ws/README.md`
- Modify: `migrate_ws/dependencies/dependencies.txt` only if a command or version clarification is needed
- Test: `migrate_ws/test/test_readme_commands.sh`
**Interfaces:**
- Consumes: final package names, preparation outputs, install paths, launch defaults, service contract, and architecture behavior from Tasks 1-6.
- Produces: a self-contained quick-start document that lets a teammate reach the first successful service call without reading repository history.
- [ ] **Step 1: Add documentation command checks.**
Create `test/test_readme_commands.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
readme="${root}/README.md"
for required in \
"smart_healthcare_2026/src/mtran" \
"./dependencies/auto_install.sh" \
"colcon build --packages-select mtran" \
"ros2 launch mtran translator.launch.py" \
"mtran/srv/TranslateEnglishToChinese" \
"/translate_en_to_zh" \
"ROS2 is not installed by" "x86_64" "aarch64" \
"records.json" "MT_OFFLINE=true" "port 8989"; do
grep -Fq "${required}" "${readme}"
done
! rg -n 'mtran_ros2|/home/hikos/MTranServer|Path.cwd|build_mtranserver.sh' "${readme}"
```
Run `bash test/test_readme_commands.sh`. Expected: FAIL because the current README uses the old nested package and repository-specific paths.
- [ ] **Step 2: Rewrite the first screen as the shortest successful path.**
Put these sections in this exact order: purpose/architecture, prerequisites, copy location, run `dependencies/auto_install.sh`, expected generated outputs, build from `smart_healthcare_2026`, source and launch, service call and successful response, service fields/error codes, warm-up/offline runtime, x86_64/aarch64 behavior, repeat/clean-up, troubleshooting, maintainer directory reference.
The first command sequence must state its working directory at every transition:
```bash
# From the parent directory containing smart_healthcare_2026:
cp -a migrate_ws smart_healthcare_2026/src/mtran
# From smart_healthcare_2026/src/mtran:
./dependencies/auto_install.sh
# From smart_healthcare_2026:
source /opt/ros/humble/setup.bash
PYTHONNOUSERSITE=1 colcon build --packages-select mtran
source install/setup.bash
# From any shell with the two setup files sourced:
PYTHONNOUSERSITE=1 ros2 launch mtran translator.launch.py
# From a second shell with both setup files sourced:
ros2 service call /translate_en_to_zh \
mtran/srv/TranslateEnglishToChinese \
"{text: 'Hello.'}"
```
Show the expected successful response with `success=True`, a Chinese translation, and `error=''`. State explicitly that the script installs non-ROS dependencies only, that `colcon build` is user-run, and that runtime needs no network after preparation/build.
- [ ] **Step 3: Add operational details and troubleshooting.**
Document the six service error codes from `translator_node.cpp`, the one-sentence/512-character scope, persistent sidecar and model warm-up, `MT_OFFLINE=true`, fixed port `8989`, launch respawn, and the exact generated output paths. Include troubleshooting entries for missing ROS Humble, apt failure, Bun/registry failure, missing or mismatched model, port conflict, sidecar restart, delayed service discovery, and `PYTHONNOUSERSITE=1` for Python user-site conflicts.
Explain that CMake consumes prepared artifacts and never downloads them. Explain that native builds on `x86_64` and `aarch64` create the matching sidecar; the ROS2 C++ source itself is portable and no x64-to-ARM cross-build is provided by the package.
- [ ] **Step 4: Validate all README commands and commit the documentation.**
Run:
```bash
bash test/test_readme_commands.sh
rg -n 'mtran_ros2|/home/hikos/MTranServer|build_mtranserver.sh' README.md
```
Expected: the first command passes; the second produces no output. Then commit:
```bash
git add migrate_ws/README.md migrate_ws/test/test_readme_commands.sh
git commit -m "docs: add portable mtran onboarding guide"
```
---
### Task 8: Verify an Independent Copied Workspace End to End
**Files:**
- Test: temporary directory outside the repository, for example `/tmp/mtran-portability-test-XXXX/smart_healthcare_2026/src/mtran`
- Modify: only the implementation files identified by a failing verification, never the user's `.vscode/`
**Interfaces:**
- Consumes: the complete package root and all scripts/assets produced by Tasks 1-7.
- Produces: evidence that the package works after being copied to the requested `smart_healthcare_2026/src/mtran` location and does not depend on `/home/hikos/MTranServer`.
- [ ] **Step 1: Copy the package to an unrelated workspace path.**
From the repository root, create a temporary workspace and copy the package without `.git`, colcon outputs, Bun cache, or generated runtime data if testing preparation from scratch:
```bash
tmp_root=$(mktemp -d)
mkdir -p "${tmp_root}/smart_healthcare_2026/src"
rsync -a --exclude '.git/' migrate_ws/ "${tmp_root}/smart_healthcare_2026/src/mtran/"
```
Assert the copied package has no source reference to `/home/hikos/MTranServer` and has `package.xml` at its root.
- [ ] **Step 2: Run preparation in the copied package.**
From the copied package root:
```bash
cd "${tmp_root}/smart_healthcare_2026/src/mtran"
./dependencies/auto_install.sh
test -x runtime/bin/mtranserver
test -s runtime/config/records.json
test -s models/en_zh-Hans/model.enzh.intgemm.alphas.bin
test -s models/en_zh-Hans/lex.50.50.enzh.s2t.bin
test -s models/en_zh-Hans/srcvocab.enzh.spm
test -s models/en_zh-Hans/trgvocab.enzh.spm
```
Expected: the script builds a native sidecar, downloads only the fixed direction, verifies all four files, and prints the user-owned `colcon` next step. On a repeated run, valid artifacts are reused.
- [ ] **Step 3: Build from the parent workspace, not the package root.**
```bash
cd "${tmp_root}/smart_healthcare_2026"
source /opt/ros/humble/setup.bash
PYTHONNOUSERSITE=1 colcon build --packages-select mtran
source install/setup.bash
test -x install/mtran/lib/mtran/mtranserver
test -f install/mtran/share/mtran/mtranserver_config/records.json
test -f install/mtran/share/mtran/models/en_zh-Hans/model.enzh.intgemm.alphas.bin
```
Expected: clean build success from the parent workspace and correct install-space paths.
- [ ] **Step 4: Run the real offline sidecar and first service call.**
From the copied workspace with setup files sourced:
```bash
PYTHONNOUSERSITE=1 ros2 launch mtran translator.launch.py
```
From a second shell with `/opt/ros/humble/setup.bash` and the copied `install/setup.bash` sourced:
```bash
ros2 service call /translate_en_to_zh \
mtran/srv/TranslateEnglishToChinese \
"{text: 'The robot has completed the inspection.'}"
```
Expected: launch logs show the sidecar and bridge, the bridge advertises the service only after health/warm-up, and the response has `success=True`, a non-empty Simplified Chinese translation, and an empty error. No runtime network request is made; `MT_OFFLINE=true` is visible in the launch process environment.
- [ ] **Step 5: Run the complete verification suite and record residual risk.**
Run from the copied workspace:
```bash
PYTHONNOUSERSITE=1 colcon test --packages-select mtran --event-handlers console_direct+
PYTHONNOUSERSITE=1 colcon test-result --verbose
cd src/mtran
python3 test/test_launch_paths.py
bash test/test_package_identity.sh
bash test/test_vendor_source.sh
bash test/test_dependency_manifest.sh
```
Expected: all automated tests pass, the real offline translation succeeds, and `git status --short` in the original repository still reports only the pre-existing unrelated `.vscode/` directory plus the intended migration commits.
- [ ] **Step 6: Commit any final verification-only corrections.**
Only after a failing end-to-end check identifies a real implementation/documentation defect, make the smallest correction and rerun the affected test plus the complete suite. Use:
```bash
git add migrate_ws
git commit -m "test: verify portable mtran workspace"
```
Do not commit copied generated outputs, temporary workspaces, `.tools`, `.build`, runtime model files, or colcon `build/`, `install/`, and `log/` directories.
---
## Final Self-Review Checklist
- [ ] `migrate_ws` itself contains `package.xml` and `CMakeLists.txt`; there is no `src/mtran_ros2` package nesting.
- [ ] All ROS identities are `mtran`; the service command uses `mtran/srv/TranslateEnglishToChinese`.
- [ ] The vendor tree is exactly commit `f5672a9`, and all generated sidecar build files are under `.build/mtranserver`.
- [ ] `auto_install.sh` is the only preparation entry point; it does not install ROS2 or run `colcon`.
- [ ] `dependencies.txt` lists every user prerequisite, apt package, Bun version/source, vendored source, endpoint, model role, and integrity method.
- [ ] CMake has no network or Bun invocation and installs all runtime data into the documented install-space paths.
- [ ] Launch has no parent-directory search, source-workspace fallback, `Path.cwd()`, or absolute local path.
- [ ] README commands use the copied `smart_healthcare_2026/src/mtran` layout and reach a first service call.
- [ ] Tests cover invalid prerequisites, unsupported architecture, missing vendor, idempotence, missing CMake artifacts, install mapping, launch paths, package identity, and the real offline call.
- [ ] A placeholder-marker scan finds no deferred or vague implementation-only steps in this plan.

View File

@@ -0,0 +1,291 @@
# MTranServer ROS2 English-to-Chinese Service Design
## 1. Goal
Build a ROS2 Humble C++ package that exposes one synchronous service for translating a single English sentence into Simplified Chinese. The package runs on Ubuntu 22.04, is built with the current machine's native toolchain, and starts a persistent MTranServer sidecar through ROS2 launch.
All new source, documentation, runtime staging files, and build outputs for this migration live under `./migrate_ws`.
## 2. Scope
The package provides exactly one operation:
```text
English string in a ROS2 service request
-> MTranServer en-to-zh-Hans translation
-> Chinese string in the ROS2 service response
```
The following features are deliberately excluded:
- Topic subscription or publication
- Batch translation
- Runtime language selection
- Automatic language detection
- HTML translation
- Long-text splitting
- Web UI integration
- Native C++ Bergamot integration
## 3. Target Environment
- ROS2 Humble
- Ubuntu 22.04
- C++17
- `ament_cmake` and `colcon`
- libcurl for HTTP
- nlohmann/json for JSON
- Bun only for producing the MTranServer sidecar executable
The same ROS2 source is compiled natively on x64 or ARM64. The current implementation and smoke test use the current machine's native MTranServer artifact. ARM64 MTranServer build and target-device deployment commands are documentation-only in this iteration.
## 4. Workspace Layout
```text
migrate_ws/
├── docs/
│ └── superpowers/
│ ├── specs/
│ └── plans/
├── models/
│ └── en_zh-Hans/
├── runtime/
│ └── bin/
│ └── mtranserver
├── src/
│ └── mtran_ros2/
│ ├── CMakeLists.txt
│ ├── package.xml
│ ├── config/
│ │ └── translator.yaml
│ ├── include/mtran_ros2/
│ │ ├── http_translation_client.hpp
│ │ └── translator_node.hpp
│ ├── launch/
│ │ └── translator.launch.py
│ ├── scripts/
│ │ └── build_mtranserver.sh
│ ├── src/
│ │ ├── http_translation_client.cpp
│ │ ├── main.cpp
│ │ └── translator_node.cpp
│ ├── srv/
│ │ └── TranslateEnglishToChinese.srv
│ └── test/
│ ├── test_http_translation_client.cpp
│ └── test_translator_node.cpp
└── README.md
```
Generated `build/`, `install/`, `log/`, the local MTranServer executable, and translation model files are not committed.
## 5. Architecture
`translator.launch.py` starts two long-lived processes:
1. `migrate_ws/runtime/bin/mtranserver`, which owns Bergamot WASM and the `en_zh-Hans` model.
2. `mtran_bridge_node`, which exposes the ROS2 service and converts service requests into local HTTP requests.
The C++ bridge communicates only with `http://127.0.0.1:8989`. It does not expose MTranServer's language, HTML, batch, or authentication features through ROS2.
The MTranServer executable remains a separate Bun build product. CMake builds only the ROS2 C++ package. The build script stages a native MTranServer binary at `migrate_ws/runtime/bin/mtranserver`; it does not create an architecture-specific ROS2 source tree.
## 6. ROS2 Interface
The service type is `mtran_ros2/srv/TranslateEnglishToChinese`:
```srv
string text
---
bool success
string translation
string error
```
The service name is fixed:
```text
/translate_en_to_zh
```
Successful response:
```yaml
success: true
translation: "..."
error: ""
```
Failed response:
```yaml
success: false
translation: ""
error: "ERROR_CODE"
```
## 7. Request Processing
The node uses `rclcpp::executors::SingleThreadedExecutor`. It performs at most one translation at a time and does not create an application-level request queue or worker pool.
For every valid request, the bridge sends:
```json
{
"from": "en",
"to": "zh-Hans",
"text": "request text",
"html": false
}
```
to `POST /translate`. A successful upstream response must be HTTP 200 with this shape:
```json
{
"result": "translated text"
}
```
The bridge returns the result only after the HTTP request completes. It does not retry a translation request.
## 8. Input Constraints
Input validation occurs before HTTP communication:
- Empty or whitespace-only input returns `INVALID_INPUT`.
- Input longer than 512 characters returns `INPUT_TOO_LONG`.
- Valid input is treated as plain English text.
The 512-character limit keeps the existing MTranServer call on its direct fixed-language path and avoids its long-text language-detection and segmentation path.
## 9. Startup and Model Residency
Launch supplies these MTranServer settings:
```text
MT_HOST=127.0.0.1
MT_PORT=8989
MT_ENABLE_UI=false
MT_OFFLINE=true
MT_CHECK_UPDATE=false
MT_MODEL_DIR=<migrate_ws/models>
MT_WORKER_IDLE_TIMEOUT=86400
MT_CACHE_SIZE=100
```
The bridge does not advertise `/translate_en_to_zh` until it has:
1. Received a successful response from `GET /health`.
2. Completed one internal `en -> zh-Hans` warm-up translation.
The existing MTranServer treats an idle timeout of zero as an immediate timeout, so this migration does not set it to zero and does not modify code outside `migrate_ws`. Instead, the bridge sends one internal warm-up translation every 43,200 seconds. Normal client requests also reset MTranServer's idle timer. The 86,400-second timeout and 43,200-second keep-warm interval keep the model resident without repeatedly starting the process.
Launch terminates both processes together. If MTranServer exits unexpectedly, launch respawns it. During the outage, service calls fail with `NOT_READY`. After health recovers, the bridge warms the model before returning to ready state.
## 10. Configuration
`translator.yaml` defines:
```yaml
mtran_bridge_node:
ros__parameters:
server_url: "http://127.0.0.1:8989"
request_timeout_ms: 30000
startup_timeout_ms: 60000
health_retry_ms: 500
keep_warm_interval_s: 43200
max_input_characters: 512
```
Language direction and HTML mode are compile-time behavior, not ROS parameters.
Launch arguments allow overriding the MTranServer executable path, model directory, offline records/configuration directory, parameter file, host, and port. Defaults point to the corresponding paths under `migrate_ws` for local development.
## 11. Error Semantics
The service returns these exact error codes:
| Code | Meaning |
| --- | --- |
| `INVALID_INPUT` | Input is empty or only whitespace. |
| `INPUT_TOO_LONG` | Input exceeds 512 characters. |
| `NOT_READY` | MTranServer is starting, unavailable, or being rewarmed. |
| `TIMEOUT` | The translation request exceeded 30 seconds. |
| `UPSTREAM_ERROR` | MTranServer returned a non-200 HTTP response or transport error. |
| `INVALID_RESPONSE` | The upstream body is not JSON containing a string `result`. |
Errors are logged without logging complete request text. Failed requests always return an empty `translation`. The original English input is never substituted as a successful result.
## 12. Component Boundaries
### `HttpTranslationClient`
Owns libcurl setup, HTTP calls, timeout handling, request JSON serialization, and response JSON parsing. It exposes health checking and fixed-direction translation methods without any ROS2 dependency.
### `TranslatorNode`
Owns ROS parameters, readiness state, input validation, service creation, startup warm-up, periodic keep-warm, and mapping client failures to service responses. It depends on the translation client through an interface so tests can inject deterministic results.
### `main.cpp`
Initializes ROS2, creates `TranslatorNode`, and spins it in a `SingleThreadedExecutor`.
### `translator.launch.py`
Resolves launch arguments, starts the MTranServer sidecar with the fixed environment, enables respawn, and starts the bridge node.
## 13. Testing
Automated tests cover:
- Empty and whitespace-only request rejection
- 513-character request rejection
- Boundary acceptance at 512 characters
- Correct fixed HTTP request payload
- Successful HTTP response parsing
- Non-200 response mapping
- Timeout mapping
- Invalid JSON and missing/non-string `result` mapping
- Service success and failure response fields
- Startup readiness transition after health and warm-up
- Recovery transition after a simulated sidecar outage
- Keep-warm scheduling using short test-only intervals
HTTP client tests use a local mock HTTP endpoint and require no model or network. Node tests inject a fake translation client.
A manual smoke test uses the native staged MTranServer binary and the local `en_zh-Hans` model:
```bash
ros2 service call /translate_en_to_zh \
mtran_ros2/srv/TranslateEnglishToChinese \
"{text: 'The robot has completed the inspection.'}"
```
The expected response has `success: true`, a non-empty Chinese `translation`, and an empty `error`.
## 14. Build and Deployment
The local workflow is:
1. Build the current machine's MTranServer executable into `migrate_ws/runtime/bin/mtranserver`.
2. Place the `en_zh-Hans` model in `migrate_ws/models/en_zh-Hans`.
3. Run `colcon build` from `migrate_ws`.
4. Source `migrate_ws/install/setup.bash`.
5. Start `translator.launch.py`.
6. Run the service smoke test.
The README also documents ARM64 deployment. On ARM64, the same C++ source is built with the target's native `colcon`; the sidecar is built with Bun's `bun-linux-arm64` target or built natively on the ARM64 device.
## 15. Acceptance Criteria
- Every new migration file is under `migrate_ws`.
- `colcon build --packages-select mtran_ros2` succeeds on ROS2 Humble and Ubuntu 22.04 when declared dependencies are installed.
- Launch starts both the native MTranServer sidecar and the C++ bridge.
- The ROS2 service appears only after health checking and model warm-up succeed.
- A valid English sentence of at most 512 characters returns Simplified Chinese in the service response.
- Empty, oversized, unavailable, timeout, upstream, and malformed-response cases return the specified error code and an empty translation.
- Translation work is serialized on one executor thread.
- The model remains warm during idle operation through the keep-warm mechanism.
- Runtime translation works without network access after the executable and model are staged.
- README includes local native build instructions and separate ARM64 build/deployment instructions.

View File

@@ -0,0 +1,345 @@
# Portable `mtran` ROS2 Package Design
## 1. Goal
Turn the current `migrate_ws` directory into one self-contained ROS2 source package that can be copied directly to:
```text
smart_healthcare_2026/src/mtran
```
After copying, the target machine must not need any file from the original MTranServer repository. A first-time online preparation script installs non-ROS dependencies, builds a native MTranServer sidecar, and downloads the fixed English-to-Simplified-Chinese runtime data. The user remains responsible for installing ROS2 Humble and running `colcon build` from `smart_healthcare_2026`.
## 2. Fixed Product Scope
The package provides one synchronous ROS2 service:
```text
/translate_en_to_zh
```
The request contains one English string. The response contains success status, the Simplified Chinese translation, and an error code.
The migration does not add topics, multiple languages, batch translation, HTML support, automatic language detection, or native C++ Bergamot integration.
## 3. Target Environment
- Ubuntu 22.04
- ROS2 Humble already installed by the user
- Supported CPU architectures: `x86_64` and `aarch64`
- C++17
- Initial preparation may access apt, npm, Mozilla translation-model endpoints, and package registries
- Runtime must work without network access after preparation and `colcon build`
The preparation script must not configure ROS apt repositories, install `ros-humble-ros-base`, or execute `colcon build`.
## 4. Package Identity and Interface
The ROS2 package and source-directory name are both `mtran`.
The service type becomes:
```text
mtran/srv/TranslateEnglishToChinese
```
The service definition remains:
```srv
string text
---
bool success
string translation
string error
```
The executable and node names remain `mtran_bridge_node`. Existing source namespaces change from `mtran_ros2` to `mtran`.
The resulting commands are:
```bash
colcon build --packages-select mtran
ros2 launch mtran translator.launch.py
ros2 service call /translate_en_to_zh \
mtran/srv/TranslateEnglishToChinese \
"{text: 'Hello.'}"
```
## 5. Final Directory Layout
The source package contains:
```text
mtran/
├── CMakeLists.txt
├── package.xml
├── README.md
├── .gitignore
├── config/
│ └── translator.yaml
├── dependencies/
│ ├── auto_install.sh
│ ├── dependencies.txt
│ └── tools/
├── docs/
│ └── superpowers/
├── include/
│ └── mtran/
│ ├── http_translation_client.hpp
│ └── translator_node.hpp
├── launch/
│ └── translator.launch.py
├── models/
│ └── en_zh-Hans/
├── runtime/
│ ├── bin/
│ │ └── mtranserver
│ └── config/
│ └── records.json
├── src/
│ ├── http_translation_client.cpp
│ ├── main.cpp
│ └── translator_node.cpp
├── srv/
│ └── TranslateEnglishToChinese.srv
├── test/
│ ├── test_http_translation_client.cpp
│ └── test_translator_node.cpp
├── vendor/
│ └── mtranserver/
└── .build/
└── mtranserver/
```
`dependencies/tools`, `.build`, `runtime/bin/mtranserver`, `runtime/config`, model files, colcon outputs, Python bytecode, and logs are generated and ignored by Git. `vendor/mtranserver` is committed source.
## 6. Vendored MTranServer Source
`vendor/mtranserver` contains a fixed copy of MTranServer v4.0.33 at source commit `f5672a9`. It includes every source, lock, UI-resource, WASM, build-script, and metadata file required by the existing single-binary Bun build.
The vendored copy excludes:
- `.git`
- `migrate_ws`
- `node_modules`
- `dist`
- `release`
- local logs and caches
The vendored directory is treated as read-only input. Build-generated files must never be written there.
## 7. Build Staging Boundary
`dependencies/auto_install.sh` resolves all paths relative to its own location:
```bash
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
package_root=$(cd "${script_dir}/.." && pwd -P)
vendor_source="${package_root}/vendor/mtranserver"
build_dir="${package_root}/.build/mtranserver"
runtime_bin_dir="${package_root}/runtime/bin"
```
The build sequence is:
```text
vendor/mtranserver
-> rsync into .build/mtranserver
-> install Bun packages in .build/mtranserver
-> run the existing single-binary build in .build/mtranserver
-> copy dist/mtranserver to runtime/bin/mtranserver
```
The script installs workspace-local Bun under `dependencies/tools`. Bun caches must also remain below `dependencies/tools`. Electron binary downloads are disabled because the sidecar does not use Electron.
Running on x86_64 produces an x86-64 sidecar. Running on aarch64 produces an ARM64 sidecar. The script rejects unsupported architectures before downloading build dependencies.
## 8. `auto_install.sh` Responsibilities
The script performs these operations in order:
1. Validate Ubuntu 22.04.
2. Validate that `ROS_DISTRO=humble` or `/opt/ros/humble` exists.
3. Validate CPU architecture is `x86_64` or `aarch64`.
4. Verify required ROS2 Humble packages are present; report missing ROS packages without installing them.
5. Install required non-ROS apt packages with `sudo apt-get`.
6. Install pinned workspace-local Bun.
7. Copy the vendored source into `.build/mtranserver`.
8. Install locked Bun dependencies and build the native sidecar.
9. Stage the executable at `runtime/bin/mtranserver` and verify its architecture.
10. Download Mozilla `translations-models-v2` records into `runtime/config/records.json`.
11. Download only the `en -> zh-Hans` model into `models/en_zh-Hans`.
12. Verify the four required model files and their hashes from the Mozilla records.
13. Print the exact `colcon build` and launch commands the user should run next.
The script does not source a user workspace, run `colcon`, modify parent directories, install ROS2, or start a persistent server.
The script is idempotent. It reuses valid Bun tools, downloaded files, and native artifacts. It rebuilds or redownloads an artifact when that artifact is missing, has the wrong architecture, or fails integrity validation.
Any failed command terminates the script with a non-zero status. Temporary downloads use a temporary filename and are renamed only after verification, so interrupted downloads cannot appear valid.
## 9. Dependency Manifest
`dependencies/dependencies.txt` is a human-readable manifest, not an executable package-manager lock file. It records:
### User-Provided Prerequisites
- Ubuntu 22.04
- ROS2 Humble
- Required ROS packages: `ament_cmake`, `ament_cmake_gtest`, `rclcpp`, `rosidl_default_generators`, `rosidl_default_runtime`, `launch`, and `launch_ros`
These are checked but not installed by `auto_install.sh`.
### Installed Apt Packages
- `build-essential`
- `cmake`
- `pkg-config`
- `curl`
- `ca-certificates`
- `rsync`
- `npm`
- `libcurl4-openssl-dev`
- `nlohmann-json3-dev`
### Workspace Tools
- Bun 1.3.14
- Installation method and package-registry source
### Included Source
- MTranServer 4.0.33
- Commit `f5672a9`
- Upstream repository URL
- Apache-2.0 license reference
### Downloaded Runtime Data
- Mozilla translations-models-v2 records endpoint
- Mozilla attachment CDN base URL
- Exact direction `en -> zh-Hans`
- Expected model file roles: model, lexicon, source vocabulary, target vocabulary
- Integrity checks based on `decompressedHash` from `records.json`
The manifest must distinguish user-provided prerequisites, packages installed by the script, included source, and generated runtime artifacts.
## 10. CMake and Install Space
`CMakeLists.txt` never downloads from the network and never invokes Bun. It requires the preparation outputs before configuration:
```text
runtime/bin/mtranserver
runtime/config/records.json
models/en_zh-Hans/<four model files>
```
If any required output is missing, CMake stops with a message instructing the user to run:
```bash
./dependencies/auto_install.sh
```
CMake installs:
```text
runtime/bin/mtranserver
-> install/mtran/lib/mtran/mtranserver
models/en_zh-Hans
-> install/mtran/share/mtran/models/en_zh-Hans
runtime/config/records.json
-> install/mtran/share/mtran/mtranserver_config/records.json
```
The launch file resolves all defaults from the installed package prefix and share directory. It must not search upward for the source workspace or contain an absolute local path.
## 11. Runtime Behavior
Launch starts the installed MTranServer and C++ bridge. MTranServer receives:
```text
MT_HOST=127.0.0.1
MT_PORT=8989
MT_ENABLE_UI=false
MT_OFFLINE=true
MT_CHECK_UPDATE=false
MT_CONFIG_DIR=<installed mtranserver_config directory>
MT_MODEL_DIR=<installed models directory>
MT_WORKER_IDLE_TIMEOUT=86400
MT_CACHE_SIZE=100
```
The bridge waits for health and a successful warm-up translation before advertising `/translate_en_to_zh`. It continues checking sidecar health, serializes requests on one executor thread, and preserves the existing validation and error semantics.
## 12. README Onboarding Requirements
README.md is written for a teammate who knows basic shell and ROS2 commands but has never seen MTranServer or this package.
The first screen of the README must answer:
- What the package does
- Which operating system and ROS2 version it expects
- Where to copy the directory
- The shortest path from a copied directory to the first successful translation
The README contains these sections in this order:
1. Purpose and architecture in plain language
2. Prerequisites, including the explicit statement that ROS2 is not installed by the script
3. Copy/install directory example under `smart_healthcare_2026/src/mtran`
4. Run `dependencies/auto_install.sh`
5. Expected preparation outputs and how to verify them
6. Run `colcon build --packages-select mtran` from `smart_healthcare_2026`
7. Source the workspace and launch the package
8. Call `/translate_en_to_zh` and show a successful response
9. Explain service fields and error codes
10. Explain persistent model warm-up and offline runtime
11. x86_64 and ARM64 behavior
12. Repeat installation, clean rebuild, and generated-directory cleanup
13. Troubleshooting for missing ROS Humble, apt failure, Bun failure, missing model, port 8989 conflict, sidecar restart, service discovery delay, and Python user-site conflicts
14. Directory reference for maintainers
Every command block states the directory from which it is run. Expected success output is shown after preparation, build, launch, and service-call commands. README commands use relative paths wherever possible so the copied workspace location can vary.
## 13. Testing and Verification
Automated verification covers:
- Package name and generated service namespace are `mtran`
- Existing HTTP client and node tests pass after namespace changes
- `auto_install.sh` shell syntax
- Package-root resolution independent of absolute workspace path
- Rejection of unsupported OS, missing ROS Humble, and unsupported CPU architecture
- Idempotent detection of already-valid generated files
- Failure when vendored source is missing
- CMake failure with a clear instruction when preparation outputs are missing
- CMake success after fixture preparation outputs exist
- Launch defaults resolve only from install space
- Clean `colcon build --packages-select mtran`
- Full ROS2 test suite
- Real offline launch and `mtran/srv/TranslateEnglishToChinese` service call
- Copying the package to a temporary `smart_healthcare_2026/src/mtran` workspace and building it without access to the original repository tree
The final real translation smoke test uses:
```text
The robot has completed the inspection.
```
and requires a successful response with a non-empty Chinese translation.
## 14. Acceptance Criteria
- The entire package can be copied as one `mtran` directory.
- No script or launch file refers to `/home/hikos/MTranServer` or a parent source tree.
- The package is named `mtran` and exposes `mtran/srv/TranslateEnglishToChinese`.
- Vendored MTranServer source is sufficient to rebuild the sidecar without the original repository.
- `dependencies/auto_install.sh` installs non-ROS dependencies, builds the sidecar, and downloads/verifies runtime data.
- `auto_install.sh` does not install ROS2 and does not execute `colcon`.
- `dependencies/dependencies.txt` completely describes user-provided, installed, included, and downloaded dependencies.
- `colcon build --packages-select mtran` runs only after preparation and performs no network access.
- Launch uses only install-space resources.
- Runtime translation remains offline and keeps the model resident.
- README enables a new teammate to reach a successful service call without knowledge of the original repository.

View File

@@ -0,0 +1,70 @@
#pragma once
#include <functional>
#include <string>
namespace mtran
{
enum class ClientError
{
kNone,
kUnavailable,
kTimeout,
kUpstream,
kInvalidResponse,
};
struct TranslationResult
{
bool success;
std::string translation;
ClientError error;
};
struct HttpResponse
{
int transport_code;
long status_code;
std::string body;
};
using HttpExecutor = std::function<HttpResponse(
const std::string & method,
const std::string & url,
const std::string & body,
long timeout_ms)>;
class TranslationClient
{
public:
virtual ~TranslationClient() = default;
virtual bool health() = 0;
virtual TranslationResult translate(const std::string & text) = 0;
};
class HttpTranslationClient final : public TranslationClient
{
public:
HttpTranslationClient(
std::string server_url,
long timeout_ms,
HttpExecutor executor = {});
bool health() override;
TranslationResult translate(const std::string & text) override;
private:
static HttpResponse execute_with_curl(
const std::string & method,
const std::string & url,
const std::string & body,
long timeout_ms);
std::string server_url_;
long timeout_ms_;
HttpExecutor executor_;
};
} // namespace mtran

View File

@@ -0,0 +1,54 @@
#pragma once
#include <rclcpp/rclcpp.hpp>
#include <chrono>
#include <memory>
#include <string>
#include "mtran/http_translation_client.hpp"
#include "mtran/srv/translate_english_to_chinese.hpp"
namespace mtran
{
class TranslatorNode : public rclcpp::Node
{
public:
explicit TranslatorNode(
const rclcpp::NodeOptions & options = rclcpp::NodeOptions(),
std::shared_ptr<TranslationClient> client = nullptr);
private:
using TranslateService = mtran::srv::TranslateEnglishToChinese;
void poll_readiness();
void keep_warm();
void mark_not_ready();
void create_service_if_needed();
void handle_request(
const TranslateService::Request::SharedPtr request,
TranslateService::Response::SharedPtr response);
static std::size_t utf8_codepoint_count(const std::string & text);
static bool is_blank(const std::string & text);
static std::string error_code(ClientError error);
std::shared_ptr<TranslationClient> client_;
rclcpp::Service<TranslateService>::SharedPtr service_;
rclcpp::TimerBase::SharedPtr health_timer_;
rclcpp::TimerBase::SharedPtr keep_warm_timer_;
bool ready_{false};
bool ever_ready_{false};
bool startup_timeout_logged_{false};
std::chrono::steady_clock::time_point startup_started_;
int request_timeout_ms_;
int startup_timeout_ms_;
int health_retry_ms_;
int keep_warm_interval_s_;
int max_input_characters_;
};
} // namespace mtran

View File

@@ -0,0 +1,88 @@
from pathlib import Path
from ament_index_python.packages import get_package_prefix, get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, ExecuteProcess, OpaqueFunction
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def _launch_processes(context):
server_executable = LaunchConfiguration("server_executable").perform(context)
model_dir = LaunchConfiguration("model_dir").perform(context)
config_dir = LaunchConfiguration("config_dir").perform(context)
params_file = LaunchConfiguration("params_file").perform(context)
host = LaunchConfiguration("host").perform(context)
port = LaunchConfiguration("port").perform(context)
sidecar = ExecuteProcess(
cmd=[server_executable],
name="mtranserver",
output="screen",
additional_env={
"MT_HOST": host,
"MT_PORT": port,
"MT_ENABLE_UI": "false",
"MT_OFFLINE": "true",
"MT_CHECK_UPDATE": "false",
"MT_CONFIG_DIR": config_dir,
"MT_MODEL_DIR": model_dir,
"MT_WORKER_IDLE_TIMEOUT": "86400",
"MT_CACHE_SIZE": "100",
},
respawn=True,
respawn_delay=2.0,
)
bridge = Node(
package="mtran",
executable="mtran_bridge_node",
name="mtran_bridge_node",
output="screen",
parameters=[
params_file,
{"server_url": f"http://{host}:{port}"},
],
)
return [sidecar, bridge]
def generate_launch_description() -> LaunchDescription:
package_prefix = Path(get_package_prefix("mtran"))
package_share = Path(get_package_share_directory("mtran"))
return LaunchDescription(
[
DeclareLaunchArgument(
"server_executable",
default_value=str(package_prefix / "lib" / "mtran" / "mtranserver"),
description="Path to the native MTranServer executable.",
),
DeclareLaunchArgument(
"model_dir",
default_value=str(package_share / "models"),
description="Directory containing the en_zh-Hans model.",
),
DeclareLaunchArgument(
"config_dir",
default_value=str(package_share / "mtranserver_config"),
description="Directory containing the offline model records file.",
),
DeclareLaunchArgument(
"params_file",
default_value=str(package_share / "config" / "translator.yaml"),
description="ROS2 parameter file for mtran_bridge_node.",
),
DeclareLaunchArgument(
"host",
default_value="127.0.0.1",
description="Loopback host used by MTranServer and the bridge.",
),
DeclareLaunchArgument(
"port",
default_value="8989",
description="HTTP port used by MTranServer and the bridge.",
),
OpaqueFunction(function=_launch_processes),
]
)

View File

27
src/mtran/package.xml Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0"?>
<package format="3">
<name>mtran</name>
<version>0.1.0</version>
<description>Persistent English-to-Chinese translation service backed by MTranServer.</description>
<maintainer email="maintainer@example.com">MTranServer Maintainers</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<exec_depend>ament_index_python</exec_depend>
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>
<depend>rclcpp</depend>
<depend>libcurl4-openssl-dev</depend>
<depend>nlohmann-json3-dev</depend>
<test_depend>ament_cmake_gtest</test_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>

View File

@@ -0,0 +1,137 @@
#include "mtran/http_translation_client.hpp"
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
#include <utility>
namespace mtran
{
namespace
{
size_t append_response(char * data, size_t size, size_t count, void * output)
{
const auto byte_count = size * count;
static_cast<std::string *>(output)->append(data, byte_count);
return byte_count;
}
bool is_unavailable(CURLcode code)
{
return code == CURLE_COULDNT_CONNECT ||
code == CURLE_COULDNT_RESOLVE_HOST ||
code == CURLE_COULDNT_RESOLVE_PROXY;
}
TranslationResult failure(ClientError error)
{
return TranslationResult{false, "", error};
}
} // namespace
HttpTranslationClient::HttpTranslationClient(
std::string server_url,
long timeout_ms,
HttpExecutor executor)
: server_url_(std::move(server_url)),
timeout_ms_(timeout_ms),
executor_(std::move(executor))
{
while (!server_url_.empty() && server_url_.back() == '/') {
server_url_.pop_back();
}
if (!executor_) {
executor_ = &HttpTranslationClient::execute_with_curl;
}
}
bool HttpTranslationClient::health()
{
const auto response = executor_("GET", server_url_ + "/health", "", timeout_ms_);
return response.transport_code == CURLE_OK &&
response.status_code >= 200 && response.status_code < 300;
}
TranslationResult HttpTranslationClient::translate(const std::string & text)
{
const nlohmann::json request = {
{"from", "en"},
{"to", "zh-Hans"},
{"text", text},
{"html", false},
};
const auto response = executor_(
"POST", server_url_ + "/translate", request.dump(), timeout_ms_);
const auto transport_code = static_cast<CURLcode>(response.transport_code);
if (transport_code == CURLE_OPERATION_TIMEDOUT) {
return failure(ClientError::kTimeout);
}
if (is_unavailable(transport_code)) {
return failure(ClientError::kUnavailable);
}
if (transport_code != CURLE_OK || response.status_code < 200 || response.status_code >= 300) {
return failure(ClientError::kUpstream);
}
try {
const auto parsed = nlohmann::json::parse(response.body);
if (!parsed.contains("result") || !parsed.at("result").is_string()) {
return failure(ClientError::kInvalidResponse);
}
return TranslationResult{true, parsed.at("result").get<std::string>(), ClientError::kNone};
} catch (const nlohmann::json::exception &) {
return failure(ClientError::kInvalidResponse);
}
}
HttpResponse HttpTranslationClient::execute_with_curl(
const std::string & method,
const std::string & url,
const std::string & body,
long timeout_ms)
{
static const auto global_init_code = curl_global_init(CURL_GLOBAL_DEFAULT);
if (global_init_code != CURLE_OK) {
return HttpResponse{global_init_code, 0, ""};
}
auto * handle = curl_easy_init();
if (handle == nullptr) {
return HttpResponse{CURLE_FAILED_INIT, 0, ""};
}
std::string response_body;
curl_slist * headers = nullptr;
curl_easy_setopt(handle, CURLOPT_URL, url.c_str());
curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT_MS, std::min(timeout_ms, 5000L));
curl_easy_setopt(handle, CURLOPT_TIMEOUT_MS, timeout_ms);
curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, append_response);
curl_easy_setopt(handle, CURLOPT_WRITEDATA, &response_body);
if (method == "POST") {
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(handle, CURLOPT_POST, 1L);
curl_easy_setopt(handle, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(handle, CURLOPT_POSTFIELDSIZE, static_cast<long>(body.size()));
}
const auto transport_code = curl_easy_perform(handle);
long status_code = 0;
curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status_code);
if (headers != nullptr) {
curl_slist_free_all(headers);
}
curl_easy_cleanup(handle);
return HttpResponse{transport_code, status_code, std::move(response_body)};
}
} // namespace mtran

16
src/mtran/src/main.cpp Normal file
View File

@@ -0,0 +1,16 @@
#include <rclcpp/rclcpp.hpp>
#include <memory>
#include "mtran/translator_node.hpp"
int main(int argc, char ** argv)
{
rclcpp::init(argc, argv);
auto node = std::make_shared<mtran::TranslatorNode>();
rclcpp::executors::SingleThreadedExecutor executor;
executor.add_node(node);
executor.spin();
rclcpp::shutdown();
return 0;
}

View File

@@ -0,0 +1,190 @@
#include "mtran/translator_node.hpp"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <functional>
#include <memory>
#include <string>
#include <utility>
namespace mtran
{
TranslatorNode::TranslatorNode(
const rclcpp::NodeOptions & options,
std::shared_ptr<TranslationClient> client)
: Node("mtran_bridge_node", options),
client_(std::move(client)),
startup_started_(std::chrono::steady_clock::now()),
request_timeout_ms_(declare_parameter<int>("request_timeout_ms", 30000)),
startup_timeout_ms_(declare_parameter<int>("startup_timeout_ms", 60000)),
health_retry_ms_(declare_parameter<int>("health_retry_ms", 500)),
keep_warm_interval_s_(declare_parameter<int>("keep_warm_interval_s", 43200)),
max_input_characters_(declare_parameter<int>("max_input_characters", 512))
{
const auto server_url = declare_parameter<std::string>(
"server_url", "http://127.0.0.1:8989");
if (!client_) {
client_ = std::make_shared<HttpTranslationClient>(server_url, request_timeout_ms_);
}
health_retry_ms_ = std::max(1, health_retry_ms_);
keep_warm_interval_s_ = std::max(1, keep_warm_interval_s_);
max_input_characters_ = std::max(1, max_input_characters_);
health_timer_ = create_wall_timer(
std::chrono::milliseconds(health_retry_ms_),
std::bind(&TranslatorNode::poll_readiness, this));
}
void TranslatorNode::poll_readiness()
{
if (ready_) {
if (!client_->health()) {
RCLCPP_WARN(get_logger(), "MTranServer health check failed; entering recovery");
mark_not_ready();
}
return;
}
if (!ever_ready_ && !startup_timeout_logged_ && startup_timeout_ms_ > 0 &&
std::chrono::steady_clock::now() - startup_started_ >=
std::chrono::milliseconds(startup_timeout_ms_))
{
startup_timeout_logged_ = true;
RCLCPP_ERROR(
get_logger(), "MTranServer did not become ready within %d ms; continuing to retry",
startup_timeout_ms_);
}
if (!client_->health()) {
return;
}
const auto warmup = client_->translate("Hello.");
if (!warmup.success) {
return;
}
ready_ = true;
ever_ready_ = true;
create_service_if_needed();
if (!keep_warm_timer_) {
keep_warm_timer_ = create_wall_timer(
std::chrono::seconds(keep_warm_interval_s_),
std::bind(&TranslatorNode::keep_warm, this));
} else {
keep_warm_timer_->reset();
}
RCLCPP_INFO(get_logger(), "MTranServer is healthy and the en-to-zh-Hans model is warm");
}
void TranslatorNode::keep_warm()
{
if (!ready_) {
return;
}
const auto result = client_->translate("Hello.");
if (!result.success && result.error == ClientError::kUnavailable) {
RCLCPP_WARN(get_logger(), "MTranServer keep-warm connection failed; entering recovery");
mark_not_ready();
} else if (!result.success) {
RCLCPP_WARN(get_logger(), "MTranServer keep-warm request failed; keeping service ready");
}
}
void TranslatorNode::mark_not_ready()
{
ready_ = false;
if (keep_warm_timer_) {
keep_warm_timer_->cancel();
}
health_timer_->reset();
}
void TranslatorNode::create_service_if_needed()
{
if (service_) {
return;
}
service_ = create_service<TranslateService>(
"/translate_en_to_zh",
std::bind(
&TranslatorNode::handle_request, this,
std::placeholders::_1, std::placeholders::_2));
}
void TranslatorNode::handle_request(
const TranslateService::Request::SharedPtr request,
TranslateService::Response::SharedPtr response)
{
response->success = false;
response->translation.clear();
response->error.clear();
if (is_blank(request->text)) {
response->error = "INVALID_INPUT";
return;
}
if (utf8_codepoint_count(request->text) >
static_cast<std::size_t>(max_input_characters_))
{
response->error = "INPUT_TOO_LONG";
return;
}
if (!ready_) {
response->error = "NOT_READY";
return;
}
const auto result = client_->translate(request->text);
if (result.success) {
response->success = true;
response->translation = result.translation;
return;
}
response->error = error_code(result.error);
if (result.error == ClientError::kUnavailable) {
mark_not_ready();
}
}
std::size_t TranslatorNode::utf8_codepoint_count(const std::string & text)
{
return static_cast<std::size_t>(std::count_if(
text.begin(), text.end(), [](unsigned char byte) {
return (byte & 0xC0U) != 0x80U;
}));
}
bool TranslatorNode::is_blank(const std::string & text)
{
return text.empty() || std::all_of(
text.begin(), text.end(), [](unsigned char character) {
return std::isspace(character) != 0;
});
}
std::string TranslatorNode::error_code(ClientError error)
{
switch (error) {
case ClientError::kUnavailable:
return "NOT_READY";
case ClientError::kTimeout:
return "TIMEOUT";
case ClientError::kInvalidResponse:
return "INVALID_RESPONSE";
case ClientError::kNone:
case ClientError::kUpstream:
default:
return "UPSTREAM_ERROR";
}
}
} // namespace mtran

View File

@@ -0,0 +1,5 @@
string text
---
bool success
string translation
string error

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
installer="${root}/dependencies/auto_install.sh"
tmp_root=$(mktemp -d)
trap 'rm -rf "${tmp_root}"' EXIT
fail()
{
echo "FAIL: $*" >&2
exit 1
}
expect_failure()
{
local expected=$1
shift
local output
if output=$("$@" 2>&1); then
fail "command unexpectedly succeeded: $*"
fi
if [[ "${output}" != *"${expected}"* ]]; then
echo "${output}" >&2
fail "failure did not contain: ${expected}"
fi
}
make_os_release()
{
local path=$1
local id=$2
local version=$3
printf 'ID=%s\nVERSION_ID="%s"\n' "${id}" "${version}" > "${path}"
}
make_fixture()
{
local fixture=$1
mkdir -p "${fixture}/dependencies" "${fixture}/vendor/mtranserver"
cp "${installer}" "${fixture}/dependencies/auto_install.sh"
cp "${root}/dependencies/verify_runtime_data.py" "${fixture}/dependencies/verify_runtime_data.py"
printf '{"version":"4.0.33"}\n' > "${fixture}/vendor/mtranserver/package.json"
printf 'lockfileVersion = 1\n' > "${fixture}/vendor/mtranserver/bun.lock"
}
make_fake_path()
{
local fake_bin=$1
local actions=$2
mkdir -p "${fake_bin}"
cat > "${fake_bin}/dpkg-query" <<'EOF'
#!/usr/bin/env bash
case "${@: -1}" in
pkg-config | npm) exit 1 ;;
esac
printf 'install ok installed\n'
EOF
cat > "${fake_bin}/sudo" <<EOF
#!/usr/bin/env bash
printf 'sudo %s\n' "\$*" >> "${actions}"
exit 0
EOF
cat > "${fake_bin}/file" <<'EOF'
#!/usr/bin/env bash
printf 'ELF 64-bit LSB executable, x86-64\n'
EOF
for command in npm rsync; do
cat > "${fake_bin}/${command}" <<EOF
#!/usr/bin/env bash
printf '${command} %s\n' "\$*" >> "${actions}"
exit 97
EOF
done
chmod +x "${fake_bin}"/*
}
make_valid_runtime()
{
local fixture=$1
local actions=$2
local model_dir="${fixture}/models/en_zh-Hans"
local records_dir="${fixture}/runtime/config"
local runtime_bin="${fixture}/runtime/bin"
local tools_bin="${fixture}/dependencies/tools/node_modules/.bin"
mkdir -p "${model_dir}" "${records_dir}" "${runtime_bin}" "${tools_bin}"
local model='model-data'
local lex='lex-data'
local src='source-vocab'
local trg='target-vocab'
printf '%s' "${model}" > "${model_dir}/model.enzh.intgemm.alphas.bin"
printf '%s' "${lex}" > "${model_dir}/lex.50.50.enzh.s2t.bin"
printf '%s' "${src}" > "${model_dir}/srcvocab.enzh.spm"
printf '%s' "${trg}" > "${model_dir}/trgvocab.enzh.spm"
local model_hash lex_hash src_hash trg_hash
model_hash=$(sha256sum "${model_dir}/model.enzh.intgemm.alphas.bin" | cut -d' ' -f1)
lex_hash=$(sha256sum "${model_dir}/lex.50.50.enzh.s2t.bin" | cut -d' ' -f1)
src_hash=$(sha256sum "${model_dir}/srcvocab.enzh.spm" | cut -d' ' -f1)
trg_hash=$(sha256sum "${model_dir}/trgvocab.enzh.spm" | cut -d' ' -f1)
cat > "${records_dir}/records.json" <<EOF
{"data":[
{"sourceLanguage":"en","targetLanguage":"zh-Hans","fileType":"model","version":"1.0","decompressedHash":"${model_hash}","decompressedSize":${#model},"attachment":{"filename":"model.enzh.intgemm.alphas.bin.zst"}},
{"sourceLanguage":"en","targetLanguage":"zh-Hans","fileType":"lex","version":"1.0","decompressedHash":"${lex_hash}","decompressedSize":${#lex},"attachment":{"filename":"lex.50.50.enzh.s2t.bin.zst"}},
{"sourceLanguage":"en","targetLanguage":"zh-Hans","fileType":"srcvocab","version":"1.0","decompressedHash":"${src_hash}","decompressedSize":${#src},"attachment":{"filename":"srcvocab.enzh.spm.zst"}},
{"sourceLanguage":"en","targetLanguage":"zh-Hans","fileType":"trgvocab","version":"1.0","decompressedHash":"${trg_hash}","decompressedSize":${#trg},"attachment":{"filename":"trgvocab.enzh.spm.zst"}}
]}
EOF
cat > "${runtime_bin}/mtranserver" <<EOF
#!/usr/bin/env bash
printf 'sidecar %s\n' "\$*" >> "${actions}"
exit 96
EOF
cat > "${tools_bin}/bun" <<EOF
#!/usr/bin/env bash
if [[ "\${1:-}" == '--version' ]]; then
printf '1.3.14\n'
exit 0
fi
printf 'bun %s\n' "\$*" >> "${actions}"
exit 95
EOF
chmod +x "${runtime_bin}/mtranserver" "${tools_bin}/bun"
}
bash -n "${installer}"
ubuntu_release="${tmp_root}/ubuntu-release"
other_release="${tmp_root}/other-release"
make_os_release "${ubuntu_release}" ubuntu 22.04
make_os_release "${other_release}" debian 12
expect_failure "Ubuntu 22.04" env \
MTRAN_OS_RELEASE_FILE="${other_release}" \
MTRAN_MACHINE=x86_64 \
ROS_DISTRO=humble \
bash "${installer}"
expect_failure "x86_64 or aarch64" env \
MTRAN_OS_RELEASE_FILE="${ubuntu_release}" \
MTRAN_MACHINE=ppc64le \
ROS_DISTRO=humble \
bash "${installer}"
expect_failure "ROS2 Humble" env -u ROS_DISTRO \
MTRAN_OS_RELEASE_FILE="${ubuntu_release}" \
MTRAN_MACHINE=x86_64 \
bash "${installer}"
missing_vendor="${tmp_root}/missing-vendor"
mkdir -p "${missing_vendor}/dependencies"
cp "${installer}" "${missing_vendor}/dependencies/auto_install.sh"
expect_failure "vendor/mtranserver" env \
MTRAN_OS_RELEASE_FILE="${ubuntu_release}" \
MTRAN_MACHINE=x86_64 \
ROS_DISTRO=humble \
bash "${missing_vendor}/dependencies/auto_install.sh"
fixture="${tmp_root}/copied-workspace/src/mtran"
actions="${tmp_root}/actions.log"
fake_bin="${tmp_root}/fake-bin"
: > "${actions}"
make_fixture "${fixture}"
make_fake_path "${fake_bin}" "${actions}"
make_valid_runtime "${fixture}" "${actions}"
PATH="${fake_bin}:/usr/bin:/bin" \
MTRAN_OS_RELEASE_FILE="${ubuntu_release}" \
MTRAN_MACHINE=x86_64 \
ROS_DISTRO=humble \
bash "${fixture}/dependencies/auto_install.sh"
if grep -Eq '^(npm|rsync|bun|sidecar) ' "${actions}"; then
cat "${actions}" >&2
fail "valid generated artifacts were rebuilt or downloaded"
fi
if grep -Eq '^sudo ' "${actions}"; then
cat "${actions}" >&2
fail "already-installed apt dependencies still invoked sudo"
fi
build_fixture="${tmp_root}/build-workspace/src/mtran"
build_actions="${tmp_root}/build-actions.log"
build_fake_bin="${tmp_root}/build-fake-bin"
make_fixture "${build_fixture}"
make_fake_path "${build_fake_bin}" "${build_actions}"
make_valid_runtime "${build_fixture}" "${build_actions}"
rm "${build_fixture}/runtime/bin/mtranserver"
cat > "${build_fake_bin}/rsync" <<'EOF'
#!/usr/bin/env bash
source_dir=${@: -2:1}
destination=${@: -1}
mkdir -p "${destination}"
cp -a "${source_dir}/." "${destination}/"
EOF
cat > "${build_fixture}/dependencies/tools/node_modules/.bin/bun" <<EOF
#!/usr/bin/env bash
if [[ "\${1:-}" == '--version' ]]; then
printf '1.3.14\n'
exit 0
fi
if [[ "\${BUN_INSTALL_CACHE_DIR:-}" != "${build_fixture}/dependencies/tools/bun-cache" ]]; then
echo 'BUN_INSTALL_CACHE_DIR was not propagated' >&2
exit 94
fi
if [[ "\${ELECTRON_SKIP_BINARY_DOWNLOAD:-}" != '1' ]]; then
echo 'ELECTRON_SKIP_BINARY_DOWNLOAD was not propagated' >&2
exit 93
fi
if [[ "\$(command -v bun)" != "${build_fixture}/dependencies/tools/node_modules/.bin/bun" ]]; then
echo 'workspace-local Bun was not added to PATH' >&2
exit 92
fi
printf 'bun %s\n' "\$*" >> "${build_actions}"
if [[ "\${1:-}" == 'run' && "\${2:-}" == 'build' ]]; then
mkdir -p dist
printf '#!/usr/bin/env bash\nexit 0\n' > dist/mtranserver
chmod +x dist/mtranserver
fi
EOF
chmod +x "${build_fake_bin}/rsync" \
"${build_fixture}/dependencies/tools/node_modules/.bin/bun"
PATH="${build_fake_bin}:/usr/bin:/bin" \
MTRAN_OS_RELEASE_FILE="${ubuntu_release}" \
MTRAN_MACHINE=x86_64 \
ROS_DISTRO=humble \
bash "${build_fixture}/dependencies/auto_install.sh"
test -x "${build_fixture}/runtime/bin/mtranserver"
grep -Fq 'bun install --frozen-lockfile' "${build_actions}"
grep -Fq 'bun run build' "${build_actions}"
printf 'auto_install tests passed\n'

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
fixture_root=$(mktemp -d)
trap 'rm -rf "${fixture_root}"' EXIT
fixture="${fixture_root}/mtran"
rsync -a \
--exclude '.build/' \
--exclude 'build/' \
--exclude 'install/' \
--exclude 'log/' \
--exclude 'vendor/' \
--exclude 'dependencies/tools/' \
--exclude 'runtime/' \
--exclude 'models/en_zh-Hans/' \
"${root}/" "${fixture}/"
if [[ "${ROS_DISTRO:-}" != "humble" ]]; then
echo "ROS2 Humble environment must be sourced before running this test" >&2
exit 1
fi
if missing_output=$(cmake -S "${fixture}" -B "${fixture}/build-missing" 2>&1); then
echo "CMake unexpectedly configured without prepared runtime files" >&2
exit 1
fi
grep -Fq './dependencies/auto_install.sh' <<< "${missing_output}"
grep -Fq 'runtime/bin/mtranserver' <<< "${missing_output}"
mkdir -p "${fixture}/runtime/bin" \
"${fixture}/runtime/config" \
"${fixture}/models/en_zh-Hans"
printf '#!/usr/bin/env bash\nexit 0\n' > "${fixture}/runtime/bin/mtranserver"
chmod +x "${fixture}/runtime/bin/mtranserver"
printf '{"data":[]}\n' > "${fixture}/runtime/config/records.json"
for model_file in \
model.enzh.intgemm.alphas.bin \
lex.50.50.enzh.s2t.bin \
srcvocab.enzh.spm \
trgvocab.enzh.spm; do
printf 'fixture\n' > "${fixture}/models/en_zh-Hans/${model_file}"
done
cmake -S "${fixture}" -B "${fixture}/build-ready" -DBUILD_TESTING=OFF >/dev/null
grep -Fq 'runtime/bin/mtranserver DESTINATION lib/${PROJECT_NAME}' "${fixture}/CMakeLists.txt"
grep -Fq 'models/en_zh-Hans DESTINATION share/${PROJECT_NAME}/models' "${fixture}/CMakeLists.txt"
grep -Fq 'runtime/config/records.json DESTINATION share/${PROJECT_NAME}/mtranserver_config' \
"${fixture}/CMakeLists.txt"
printf 'CMake preparation tests passed\n'

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
manifest="${root}/dependencies/dependencies.txt"
test -f "${manifest}"
required_entries=(
"Ubuntu 22.04"
"ROS2 Humble"
"ament_cmake"
"ament_cmake_gtest"
"rclcpp"
"rosidl_default_generators"
"rosidl_default_runtime"
"launch_ros"
"build-essential"
"file"
"libcurl4-openssl-dev"
"nlohmann-json3-dev"
"Bun 1.3.14"
"MTranServer 4.0.33"
"f5672a9"
"records.json"
"en -> zh-Hans"
"decompressedHash"
"auto_install.sh does not install ROS2"
"colcon build is run by the user"
)
for required in "${required_entries[@]}"; do
grep -Fq "${required}" "${manifest}"
done

View File

@@ -0,0 +1,129 @@
#include <curl/curl.h>
#include <gtest/gtest.h>
#include <string>
#include <utility>
#include "mtran/http_translation_client.hpp"
namespace mtran
{
namespace
{
TEST(HttpTranslationClientTest, SendsFixedDirectionPayloadAndReturnsTranslation)
{
std::string actual_method;
std::string actual_url;
std::string actual_body;
long actual_timeout = 0;
HttpTranslationClient client(
"http://127.0.0.1:8989/", 1234,
[&](const std::string & method, const std::string & url,
const std::string & body, long timeout_ms) {
actual_method = method;
actual_url = url;
actual_body = body;
actual_timeout = timeout_ms;
return HttpResponse{CURLE_OK, 200, R"({"result":""})"};
});
const auto result = client.translate("Hello");
EXPECT_EQ(actual_method, "POST");
EXPECT_EQ(actual_url, "http://127.0.0.1:8989/translate");
EXPECT_EQ(actual_body, R"({"from":"en","html":false,"text":"Hello","to":"zh-Hans"})");
EXPECT_EQ(actual_timeout, 1234);
EXPECT_TRUE(result.success);
EXPECT_EQ(result.translation, "你好");
EXPECT_EQ(result.error, ClientError::kNone);
}
TEST(HttpTranslationClientTest, ReportsUnavailableTransport)
{
HttpTranslationClient client(
"http://127.0.0.1:8989", 100,
[](const auto &, const auto &, const auto &, long) {
return HttpResponse{CURLE_COULDNT_CONNECT, 0, ""};
});
const auto result = client.translate("Hello");
EXPECT_FALSE(result.success);
EXPECT_TRUE(result.translation.empty());
EXPECT_EQ(result.error, ClientError::kUnavailable);
}
TEST(HttpTranslationClientTest, ReportsTimeout)
{
HttpTranslationClient client(
"http://127.0.0.1:8989", 100,
[](const auto &, const auto &, const auto &, long) {
return HttpResponse{CURLE_OPERATION_TIMEDOUT, 0, ""};
});
const auto result = client.translate("Hello");
EXPECT_FALSE(result.success);
EXPECT_TRUE(result.translation.empty());
EXPECT_EQ(result.error, ClientError::kTimeout);
}
TEST(HttpTranslationClientTest, ReportsNonSuccessHttpStatus)
{
HttpTranslationClient client(
"http://127.0.0.1:8989", 100,
[](const auto &, const auto &, const auto &, long) {
return HttpResponse{CURLE_OK, 500, "failure"};
});
const auto result = client.translate("Hello");
EXPECT_FALSE(result.success);
EXPECT_TRUE(result.translation.empty());
EXPECT_EQ(result.error, ClientError::kUpstream);
}
class InvalidResponseTest : public ::testing::TestWithParam<std::string>
{
};
TEST_P(InvalidResponseTest, RejectsMalformedOrWrongShapeResponse)
{
const auto response_body = GetParam();
HttpTranslationClient client(
"http://127.0.0.1:8989", 100,
[response_body](const auto &, const auto &, const auto &, long) {
return HttpResponse{CURLE_OK, 200, response_body};
});
const auto result = client.translate("Hello");
EXPECT_FALSE(result.success);
EXPECT_TRUE(result.translation.empty());
EXPECT_EQ(result.error, ClientError::kInvalidResponse);
}
INSTANTIATE_TEST_SUITE_P(
InvalidBodies, InvalidResponseTest,
::testing::Values("not-json", R"({"result":7})", R"({"other":"value"})"));
TEST(HttpTranslationClientTest, HealthRequiresSuccessfulTransportAndHttpStatus)
{
int call_count = 0;
HttpTranslationClient client(
"http://127.0.0.1:8989", 100,
[&](const std::string & method, const std::string & url, const std::string &, long) {
++call_count;
EXPECT_EQ(method, "GET");
EXPECT_EQ(url, "http://127.0.0.1:8989/health");
return HttpResponse{CURLE_OK, call_count == 1 ? 503 : 200, ""};
});
EXPECT_FALSE(client.health());
EXPECT_TRUE(client.health());
}
} // namespace
} // namespace mtran

View File

@@ -0,0 +1,16 @@
from pathlib import Path
root = Path(__file__).resolve().parents[1]
source = (root / "launch" / "translator.launch.py").read_text(encoding="utf-8")
assert 'get_package_prefix("mtran")' in source
assert 'get_package_share_directory("mtran")' in source
assert 'get_package_share_directory("mtran_ros2")' not in source
assert "_find_workspace_root" not in source
assert 'package="mtran"' in source
assert 'package_prefix / "lib" / "mtran" / "mtranserver"' in source
assert 'package_share / "models"' in source
assert 'package_share / "mtranserver_config"' in source
assert 'Path.cwd()' not in source
assert "/home/hikos/" not in source

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
test -f "${root}/package.xml"
test -f "${root}/CMakeLists.txt"
test ! -e "${root}/src/mtran_ros2/package.xml"
grep -q '<name>mtran</name>' "${root}/package.xml"
grep -q 'project(mtran)' "${root}/CMakeLists.txt"
if grep -q 'build_mtranserver.sh' "${root}/CMakeLists.txt"; then
echo "CMakeLists.txt still installs the removed build_mtranserver.sh" >&2
exit 1
fi
grep -q 'mtran/srv/TranslateEnglishToChinese' "${root}/README.md"
if rg -n 'mtran_ros2' "${root}" \
-g '!docs/superpowers/**' \
-g '!vendor/**' \
-g '!test/**' \
-g '!build/**' \
-g '!install/**' \
-g '!log/**'; then
echo "implementation files still reference mtran_ros2" >&2
exit 1
fi

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
readme="${root}/README.md"
required_entries=(
"smart-healthcare-2026/src/mtran"
"src/mtran/dependencies"
"./auto_install.sh"
"source ~/.bashrc"
"colcon build --packages-select mtran"
"ros2 launch mtran translator.launch.py"
"mtran/srv/TranslateEnglishToChinese"
"/translate_en_to_zh"
"脚本不会安装 ROS2"
"x86_64"
"aarch64"
"records.json"
"MT_OFFLINE=true"
"8989"
)
for required in "${required_entries[@]}"; do
grep -Fq "${required}" "${readme}"
done
if rg -n 'mtran_ros2|MTranServer/migrate_ws|Path\.cwd|build_mtranserver\.sh' "${readme}"; then
echo "README still contains an obsolete package name or local path" >&2
exit 1
fi

View File

@@ -0,0 +1,12 @@
from pathlib import Path
root = Path(__file__).resolve().parents[1]
installer = (root / "dependencies" / "auto_install.sh").read_text(encoding="utf-8")
cmake_test = (root / "test" / "test_cmake_preparation.sh").read_text(encoding="utf-8")
assert "pwd -P" in installer
assert "/opt/ros/humble" not in installer
assert "MTRAN_ROS_PREFIX" not in installer
assert "/opt/ros/humble" not in cmake_test
assert "ROS_DISTRO" in cmake_test

View File

@@ -0,0 +1,301 @@
#include <gtest/gtest.h>
#include <rclcpp/rclcpp.hpp>
#include <chrono>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "mtran/http_translation_client.hpp"
#include "mtran/srv/translate_english_to_chinese.hpp"
#include "mtran/translator_node.hpp"
using namespace std::chrono_literals;
namespace mtran
{
namespace
{
class FakeTranslationClient final : public TranslationClient
{
public:
bool health() override
{
std::lock_guard<std::mutex> lock(mutex_);
++health_calls_;
return healthy_;
}
TranslationResult translate(const std::string & text) override
{
std::lock_guard<std::mutex> lock(mutex_);
if (text == "Hello.") {
++warmup_calls_;
return warmup_result_;
}
user_inputs_.push_back(text);
return user_result_;
}
void set_healthy(bool healthy)
{
std::lock_guard<std::mutex> lock(mutex_);
healthy_ = healthy;
}
void set_warmup_result(TranslationResult result)
{
std::lock_guard<std::mutex> lock(mutex_);
warmup_result_ = std::move(result);
}
void set_user_result(TranslationResult result)
{
std::lock_guard<std::mutex> lock(mutex_);
user_result_ = std::move(result);
}
void set_all_translation_results(TranslationResult result)
{
std::lock_guard<std::mutex> lock(mutex_);
warmup_result_ = result;
user_result_ = std::move(result);
}
std::size_t user_call_count() const
{
std::lock_guard<std::mutex> lock(mutex_);
return user_inputs_.size();
}
int warmup_call_count() const
{
std::lock_guard<std::mutex> lock(mutex_);
return warmup_calls_;
}
private:
mutable std::mutex mutex_;
bool healthy_{true};
int health_calls_{0};
int warmup_calls_{0};
TranslationResult warmup_result_{true, "你好。", ClientError::kNone};
TranslationResult user_result_{true, "机器人已完成检查。", ClientError::kNone};
std::vector<std::string> user_inputs_;
};
class TranslatorNodeTest : public ::testing::Test
{
protected:
using Service = mtran::srv::TranslateEnglishToChinese;
static void SetUpTestSuite()
{
if (!rclcpp::ok()) {
int argc = 0;
rclcpp::init(argc, nullptr);
}
}
static void TearDownTestSuite()
{
rclcpp::shutdown();
}
void SetUp() override
{
fake_ = std::make_shared<FakeTranslationClient>();
rclcpp::NodeOptions options;
options.parameter_overrides(
{
rclcpp::Parameter("health_retry_ms", 1),
rclcpp::Parameter("startup_timeout_ms", 1000),
rclcpp::Parameter("keep_warm_interval_s", 1),
rclcpp::Parameter("max_input_characters", 512),
});
server_ = std::make_shared<TranslatorNode>(options, fake_);
client_node_ = std::make_shared<rclcpp::Node>("mtran_test_client");
client_ = client_node_->create_client<Service>("/translate_en_to_zh");
executor_.add_node(server_);
executor_.add_node(client_node_);
}
void TearDown() override
{
executor_.remove_node(client_node_);
executor_.remove_node(server_);
client_.reset();
client_node_.reset();
server_.reset();
fake_.reset();
}
bool spin_until_service(std::chrono::milliseconds timeout = 500ms)
{
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
executor_.spin_some();
if (client_->service_is_ready()) {
return true;
}
std::this_thread::sleep_for(1ms);
}
return false;
}
Service::Response::SharedPtr call(const std::string & text)
{
auto request = std::make_shared<Service::Request>();
request->text = text;
auto future = client_->async_send_request(request);
EXPECT_EQ(executor_.spin_until_future_complete(future, 1s), rclcpp::FutureReturnCode::SUCCESS);
return future.get();
}
rclcpp::executors::SingleThreadedExecutor executor_;
std::shared_ptr<FakeTranslationClient> fake_;
std::shared_ptr<TranslatorNode> server_;
rclcpp::Node::SharedPtr client_node_;
rclcpp::Client<Service>::SharedPtr client_;
};
TEST_F(TranslatorNodeTest, AdvertisesServiceOnlyAfterHealthAndWarmup)
{
fake_->set_healthy(false);
executor_.spin_some();
std::this_thread::sleep_for(10ms);
executor_.spin_some();
EXPECT_FALSE(client_->service_is_ready());
fake_->set_healthy(true);
EXPECT_TRUE(spin_until_service());
EXPECT_GE(fake_->warmup_call_count(), 1);
}
TEST_F(TranslatorNodeTest, ValidatesEmptyAndLengthBoundariesBeforeCallingClient)
{
ASSERT_TRUE(spin_until_service());
const auto empty = call(" ");
EXPECT_FALSE(empty->success);
EXPECT_TRUE(empty->translation.empty());
EXPECT_EQ(empty->error, "INVALID_INPUT");
const auto at_limit = call(std::string(512, 'a'));
EXPECT_TRUE(at_limit->success);
EXPECT_EQ(at_limit->translation, "机器人已完成检查。");
EXPECT_TRUE(at_limit->error.empty());
const auto over_limit = call(std::string(513, 'a'));
EXPECT_FALSE(over_limit->success);
EXPECT_TRUE(over_limit->translation.empty());
EXPECT_EQ(over_limit->error, "INPUT_TOO_LONG");
EXPECT_EQ(fake_->user_call_count(), 1U);
}
TEST_F(TranslatorNodeTest, MapsTranslationClientOutcomesToServiceErrors)
{
ASSERT_TRUE(spin_until_service());
fake_->set_user_result({false, "", ClientError::kTimeout});
EXPECT_EQ(call("timeout")->error, "TIMEOUT");
fake_->set_user_result({false, "", ClientError::kUpstream});
EXPECT_EQ(call("upstream")->error, "UPSTREAM_ERROR");
fake_->set_user_result({false, "", ClientError::kInvalidResponse});
EXPECT_EQ(call("invalid")->error, "INVALID_RESPONSE");
}
TEST_F(TranslatorNodeTest, RecoversAfterUnavailableSidecarIsHealthyAndWarmAgain)
{
ASSERT_TRUE(spin_until_service());
fake_->set_user_result({false, "", ClientError::kUnavailable});
const auto unavailable = call("first request");
EXPECT_FALSE(unavailable->success);
EXPECT_EQ(unavailable->error, "NOT_READY");
fake_->set_healthy(false);
fake_->set_user_result({true, "恢复完成", ClientError::kNone});
const auto while_down = call("second request");
EXPECT_FALSE(while_down->success);
EXPECT_EQ(while_down->error, "NOT_READY");
const auto warmups_before_recovery = fake_->warmup_call_count();
fake_->set_healthy(true);
const auto deadline = std::chrono::steady_clock::now() + 500ms;
while (fake_->warmup_call_count() == warmups_before_recovery &&
std::chrono::steady_clock::now() < deadline)
{
executor_.spin_some();
std::this_thread::sleep_for(1ms);
}
const auto recovered = call("third request");
EXPECT_TRUE(recovered->success);
EXPECT_EQ(recovered->translation, "恢复完成");
EXPECT_TRUE(recovered->error.empty());
}
TEST_F(TranslatorNodeTest, HealthMonitorDetectsRestartBeforeNextUserRequest)
{
ASSERT_TRUE(spin_until_service());
const auto user_calls_before_outage = fake_->user_call_count();
fake_->set_healthy(false);
const auto detection_deadline = std::chrono::steady_clock::now() + 100ms;
while (std::chrono::steady_clock::now() < detection_deadline) {
executor_.spin_some();
std::this_thread::sleep_for(1ms);
}
const auto during_outage = call("request during restart");
EXPECT_FALSE(during_outage->success);
EXPECT_EQ(during_outage->error, "NOT_READY");
EXPECT_EQ(fake_->user_call_count(), user_calls_before_outage);
fake_->set_healthy(true);
const auto warmups_before_recovery = fake_->warmup_call_count();
const auto recovery_deadline = std::chrono::steady_clock::now() + 500ms;
while (fake_->warmup_call_count() == warmups_before_recovery &&
std::chrono::steady_clock::now() < recovery_deadline)
{
executor_.spin_some();
std::this_thread::sleep_for(1ms);
}
const auto recovered = call("request after restart");
EXPECT_TRUE(recovered->success);
EXPECT_EQ(recovered->translation, "机器人已完成检查。");
}
TEST_F(TranslatorNodeTest, TransientKeepWarmFailureDoesNotMarkServiceNotReady)
{
ASSERT_TRUE(spin_until_service());
fake_->set_all_translation_results({false, "", ClientError::kTimeout});
const auto warmups_before_timer = fake_->warmup_call_count();
const auto deadline = std::chrono::steady_clock::now() + 1500ms;
while (fake_->warmup_call_count() == warmups_before_timer &&
std::chrono::steady_clock::now() < deadline)
{
executor_.spin_some();
std::this_thread::sleep_for(2ms);
}
ASSERT_GT(fake_->warmup_call_count(), warmups_before_timer);
fake_->set_user_result({true, "服务仍然可用", ClientError::kNone});
const auto response = call("request after transient failure");
EXPECT_TRUE(response->success);
EXPECT_EQ(response->translation, "服务仍然可用");
EXPECT_TRUE(response->error.empty());
}
} // namespace
} // namespace mtran

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
vendor="${root}/vendor/mtranserver"
test -f "${vendor}/package.json"
test -f "${vendor}/bun.lock"
test -f "${vendor}/scripts/build.ts"
test -f "${vendor}/src/main.ts"
test -f "${vendor}/ui/package.json"
test -f "${vendor}/PROVENANCE.md"
grep -Fq '"version": "4.0.33"' "${vendor}/package.json"
grep -Fq 'Source commit: f5672a9' "${vendor}/PROVENANCE.md"
for generated in .git dist node_modules release migrate_ws; do
if test -e "${vendor}/${generated}"; then
echo "vendored source contains generated path: ${generated}" >&2
exit 1
fi
done

View File

@@ -0,0 +1,82 @@
import hashlib
import json
import sys
import tempfile
import unittest
from pathlib import Path
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PACKAGE_ROOT / "dependencies"))
from verify_runtime_data import RuntimeDataError, verify_runtime_data # noqa: E402
class VerifyRuntimeDataTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.model_dir = self.root / "models" / "en_zh-Hans"
self.model_dir.mkdir(parents=True)
self.records_path = self.root / "records.json"
def tearDown(self):
self.temp_dir.cleanup()
def write_fixture(self):
records = []
fixtures = {
"model": ("model.bin", b"model"),
"lex": ("lex.bin", b"lex"),
"srcvocab": ("src.spm", b"source"),
"trgvocab": ("trg.spm", b"target"),
}
for role, (filename, content) in fixtures.items():
(self.model_dir / filename).write_bytes(content)
records.append(
{
"sourceLanguage": "en",
"targetLanguage": "zh-Hans",
"fileType": role,
"version": "1.0",
"decompressedHash": hashlib.sha256(content).hexdigest(),
"decompressedSize": len(content),
"attachment": {"filename": f"{filename}.zst"},
}
)
self.records_path.write_text(json.dumps({"data": records}))
return fixtures
def test_accepts_exact_four_verified_files(self):
fixtures = self.write_fixture()
selected = verify_runtime_data(self.records_path, self.model_dir)
self.assertEqual(set(selected), set(fixtures))
self.assertEqual(selected["model"].name, "model.bin")
def test_rejects_hash_mismatch(self):
self.write_fixture()
(self.model_dir / "model.bin").write_bytes(b"other")
with self.assertRaisesRegex(RuntimeDataError, "hash mismatch"):
verify_runtime_data(self.records_path, self.model_dir)
def test_rejects_missing_required_role(self):
self.write_fixture()
records = json.loads(self.records_path.read_text())
records["data"] = [item for item in records["data"] if item["fileType"] != "lex"]
self.records_path.write_text(json.dumps(records))
with self.assertRaisesRegex(RuntimeDataError, "missing required record role: lex"):
verify_runtime_data(self.records_path, self.model_dir)
def test_rejects_unselected_model_file(self):
self.write_fixture()
(self.model_dir / "unexpected.bin").write_bytes(b"extra")
with self.assertRaisesRegex(RuntimeDataError, "unexpected model files"):
verify_runtime_data(self.records_path, self.model_dir)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,13 @@
node_modules
dist
.git
.github
.vscode
.idea
.DS_Store
ui/node_modules
ui/dist
deprecated
tests
coverage
*.log

View File

@@ -0,0 +1,235 @@
name: Build and Release
on:
workflow_dispatch:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
build-server:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Sync version from tag
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="v$(cat package.json | grep '"version"' | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')"
fi
echo "Syncing version to ${VERSION}"
bun run bump ${VERSION}
- name: Build Server
run: bun run build:all
- name: Upload Server Artifacts
uses: actions/upload-artifact@v4
with:
name: binaries
path: dist/mtranserver-*
build-desktop:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Sync version from tag
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="v$(grep '"version"' package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')"
fi
echo "Syncing version to ${VERSION}"
bun run bump ${VERSION}
- name: Build Desktop (Linux)
if: matrix.os == 'ubuntu-latest'
run: bun run build:electron -- --linux
- name: Build Desktop (Windows)
if: matrix.os == 'windows-latest'
run: bun run build:electron -- --win
- name: Build Desktop (macOS)
if: matrix.os == 'macos-latest'
run: bun run build:electron -- --mac
- name: Upload Desktop Artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.os }}
path: release/mtranserver-desktop-*
release:
needs: [build-server, build-desktop]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download Server Artifacts
uses: actions/download-artifact@v4
with:
name: binaries
path: release-assets
- name: Download Desktop Artifacts
uses: actions/download-artifact@v4
with:
pattern: desktop-*
path: release-assets
merge-multiple: true
- name: Determine Release Tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
LATEST_TAG=$(git describe --tags --abbrev=0)
echo "tag_name=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Using latest tag: $LATEST_TAG"
else
echo "tag_name=${{ github.ref_name }}" >> $GITHUB_OUTPUT
echo "Using ref name: ${{ github.ref_name }}"
fi
- name: Generate Checksums
run: |
cd release-assets
sha256sum * > SHA256SUMS
cat SHA256SUMS
- name: Generate Release Body
id: generate_body
run: |
TAG="${{ steps.tag.outputs.tag_name }}"
REPO="${{ github.repository }}"
# Derive version from TAG to be consistent with artifacts
VERSION=${TAG#v}
# Use VERSION derived from tag instead of package.json to ensure consistency with builds
echo "Using version: $VERSION"
if [[ "${{ github.ref_name }}" == *"dev"* ]]; then
WARNING=$(printf "**This is a development release. DO NOT USE IT!**\n\n")
else
WARNING=""
fi
cat > release_body.md << 'EOF'
${WARNING}## Desktop Client
| Platform | Architecture | Binary |
|----------|-------------|--------|
| Linux | x64 | [mtranserver-desktop-${VERSION}-linux-x64.AppImage](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.AppImage) |
| Linux | arm64 | [mtranserver-desktop-${VERSION}-linux-arm64.AppImage](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.AppImage) |
| Linux | x64 (deb) | [mtranserver-desktop-${VERSION}-linux-x64.deb](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.deb) |
| Linux | arm64 (deb) | [mtranserver-desktop-${VERSION}-linux-arm64.deb](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.deb) |
| Linux | x64 (rpm) | [mtranserver-desktop-${VERSION}-linux-x64.rpm](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.rpm) |
| Linux | arm64 (rpm) | [mtranserver-desktop-${VERSION}-linux-arm64.rpm](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.rpm) |
| macOS | x64 | [mtranserver-desktop-${VERSION}-mac-x64.dmg](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-mac-x64.dmg) |
| macOS | arm64 | [mtranserver-desktop-${VERSION}-mac-arm64.dmg](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-mac-arm64.dmg) |
| Windows | x64 | [mtranserver-desktop-${VERSION}-win-x64.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-win-x64.exe) |
> 桌面端启动后会创建一个托盘菜单,通过菜单可以方便的管理服务。
**macOS**: 拖动安装后,如果打开 MTranServer App 提示有问题无法打开,需要运行以下命令修复:
> **macOS**: If you see any error when opening MTranServer App after installing, run this command in Terminal App:
```bash
sudo xattr -d com.apple.quarantine "/Applications/MTranServer.app"
```
## Server Binary
| Platform | Architecture | Binary |
|----------|-------------|--------|
| Linux | x64 | [mtranserver-${VERSION}-linux-amd64](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-linux-amd64) |
| Linux | x64 (legacy) | [mtranserver-${VERSION}-linux-amd64-legacy](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-linux-amd64-legacy) |
| Linux | ARM64 | [mtranserver-${VERSION}-linux-arm64](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-linux-arm64) |
| Linux (musl) | x64 | [mtranserver-${VERSION}-linux-amd64-musl](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-linux-amd64-musl) |
| Linux (musl) | x64 (legacy) | [mtranserver-${VERSION}-linux-amd64-musl-legacy](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-linux-amd64-musl-legacy) |
| Linux (musl) | ARM64 | [mtranserver-${VERSION}-linux-arm64-musl](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-linux-arm64-musl) |
| macOS | Intel | [mtranserver-${VERSION}-darwin-amd64](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-darwin-amd64) |
| macOS | Intel (legacy) | [mtranserver-${VERSION}-darwin-amd64-legacy](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-darwin-amd64-legacy) |
| macOS | Apple Silicon | [mtranserver-${VERSION}-darwin-arm64](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-darwin-arm64) |
| Windows | x64 | [mtranserver-${VERSION}-windows-amd64.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-windows-amd64.exe) |
| Windows | x64 (legacy) | [mtranserver-${VERSION}-windows-amd64-legacy.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-windows-amd64-legacy.exe) |
## Note
**legacy**: 仅适用于使用普通版本会遇到如 'Illegal instruction' 这样问题的旧硬件(无 AVX2 指令集的 CPU大多数人不需要这个因为它更慢。
> **legacy**: Use only if you experience 'Illegal instruction' crashes on very old hardware (non-AVX2 CPUs). Most people don't need this because it's slower.
**musl**: 仅适用于 Linux 使用 musl libc 的用户,大多数人不需要这个。
> **musl**: Use only if you are on Linux with musl libc. Most people don't need this.
## Docker
Image: `xxnuo/mtranserver:latest`
可执行文件版本主要面向个人桌面端部署,如果需要更高性能和稳定性,推荐使用 Docker 版本。
> Executable version is mainly for personal desktop deployment. If you need higher performance and stability, it is recommended to use the Docker version.
如果你的 CPU 不支持 AVX 并且使用 legacy 版本也报错,推荐使用 Docker 版本而非可执行文件版本,因为 Docker 版本会自动选择合适的架构运行且速度更快。
> If your CPU does not support AVX and using legacy version also crashes, it is recommended to use the Docker version instead of the executable file version, because the Docker version will automatically select the appropriate architecture to run and is faster.
## Changelog
EOF
sed -i "s|\${WARNING}|${WARNING}|g" release_body.md
sed -i "s|\${REPO}|${REPO}|g" release_body.md
sed -i "s|\${TAG}|${TAG}|g" release_body.md
sed -i "s|\${VERSION}|${VERSION}|g" release_body.md
if [ -f CHANGELOG.md ]; then
echo "" >> release_body.md
cat CHANGELOG.md >> release_body.md
fi
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag_name }}
name: ${{ steps.tag.outputs.tag_name }}
body_path: release_body.md
files: release-assets/*
draft: false
fail_on_unmatched_files: false

View File

@@ -0,0 +1,52 @@
name: Desktop Build
on:
workflow_dispatch:
permissions:
contents: write
jobs:
build_desktop:
runs-on: ${{ matrix.os }}
permissions:
contents: write
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Sync version from tag
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="v$(cat package.json | grep '\"version\"' | head -1 | sed 's/.*\"version\": \"\(.*\)\".*/\1/')"
fi
echo "Syncing version to ${VERSION}"
bun run bump ${VERSION}
- name: Build Desktop
run: bun run build:electron
- name: Upload Desktop Artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.os }}
path: release/mtranserver-desktop-*

View File

@@ -0,0 +1,84 @@
name: Build and Push Docker Image
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get latest tag
id: latest_tag
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "tag=${LATEST_TAG}" >> $GITHUB_OUTPUT
echo "Latest tag: ${LATEST_TAG}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
xxnuo/mtranserver
ghcr.io/xxnuo/mtranserver
flavor: |
latest=false
tags: |
type=semver,pattern={{version}},value=${{ steps.latest_tag.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.latest_tag.outputs.tag }}
type=semver,pattern={{major}},value=${{ steps.latest_tag.outputs.tag }}
type=raw,value=latest
- name: Get version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION=${{ steps.latest_tag.outputs.tag }}
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false

View File

@@ -0,0 +1,43 @@
name: Publish to NPM
on:
release:
types: [published]
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
environment: npm-publish # Requires manual approval in GitHub Settings
permissions:
contents: read
id-token: write # Required for Trusted Publishing (provenance)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Install Dependencies
run: bun install
# We run build:lib manually here to ensure it uses the CI environment correctly
# although prepublishOnly in package.json handles it too.
- name: Build Library
run: bun run build:lib
- name: Publish to NPM
# --provenance flag is key for Trusted Publishing to verify the source
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # Only if NOT using Trusted Publishing strictly, but with provenance usually automatic

27
src/mtran/vendor/mtranserver/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
*.log
*.tar
*.psd
*.bin
*.spm
*.zip
.env
*node_modules*
/packages
/models
/.bin
/dist
/.claude
/tests/ff
/tests/MTranCore
deprecated/go/bin/worker
deprecated/go/dist/mtranserver-darwin-arm64
*.o
src/generated/
src/assets/ui.ts
src/assets/swagger.ts
*.tgz
/release
/.electron-node-modules
/.electron-build-temp

81
src/mtran/vendor/mtranserver/API.md vendored Normal file
View File

@@ -0,0 +1,81 @@
# MTranServer 高级配置说明
[中文](API.md) | [English](docs/API_en.md) | [日本語](docs/API_ja.md) | [Français](docs/API_fr.md) | [Deutsch](docs/API_de.md)
### 环境变量配置
| 环境变量 | 说明 | 默认值 | 可选值 |
| --------------------- | ---------------------------------------- | ------ | --------------------------- |
| MT_LOG_LEVEL | 日志级别 | warn | debug, info, warn, error |
| MT_CONFIG_DIR | 配置目录 | ~/.config/mtran/server | 任意路径 |
| MT_MODEL_DIR | 模型目录 | ~/.config/mtran/models | 任意路径 |
| MT_HOST | 服务器监听地址 | 0.0.0.0| 任意 IP 地址 |
| MT_PORT | 服务器端口 | 8989 | 1-65535 |
| MT_ENABLE_UI | 启用 Web UI | true | true, false |
| MT_OFFLINE | 离线模式,不自动下载新语言的模型,仅使用已下载的模型 | false | true, false |
| MT_WORKER_IDLE_TIMEOUT| Worker 空闲超时时间(秒) | 300 | 任意正整数 |
| MT_API_TOKEN | API 访问令牌 | 空 | 任意字符串 |
| MT_CACHE_SIZE | 缓存大小(缓存最近的多少次翻译) | 0 | 任意正整数 |
示例:
```bash
# 设置日志级别为 debug
export MT_LOG_LEVEL=debug
# 设置端口为 9000
export MT_PORT=9000
# 启动服务
./mtranserver
```
### API 接口说明
#### 系统接口
| 接口 | 方法 | 说明 | 认证 |
| ---- | ---- | ---- | ---- |
| `/version` | GET | 获取服务版本 | 否 |
| `/health` | GET | 健康检查 | 否 |
| `/__heartbeat__` | GET | 心跳检查 | 否 |
| `/__lbheartbeat__` | GET | 负载均衡心跳检查 | 否 |
| `/docs/*` | GET | Swagger API 文档 | 否 |
#### 翻译接口
| 接口 | 方法 | 说明 | 认证 |
| ---- | ---- | ---- | ---- |
| `/languages` | GET | 获取支持的语言列表 | 是 |
| `/translate` | POST | 单文本翻译 | 是 |
| `/translate/batch` | POST | 批量翻译 | 是 |
**单文本翻译请求示例:**
```json
{
"from": "en",
"to": "zh-Hans",
"text": "Hello, world!",
"html": false
}
```
**批量翻译请求示例:**
```json
{
"from": "en",
"to": "zh-Hans",
"texts": ["Hello, world!", "Good morning!"],
"html": false
}
```
**认证方式:**
- Header: `Authorization: Bearer <token>`
- Query: `?token=<token>`
详细内容请参考服务器启动后的 API 文档内容。

View File

@@ -0,0 +1,3 @@
## v4.0.31
- 添加中文输出标点全角化功能,默认开启

View File

@@ -0,0 +1,2 @@
<img src="./images/wxpay.jpg" alt="微信" width="200">
<img src="./images/alipay.jpg" alt="支付宝" width="200">

35
src/mtran/vendor/mtranserver/Dockerfile vendored Normal file
View File

@@ -0,0 +1,35 @@
FROM oven/bun:1 AS builder
WORKDIR /app
ARG VERSION
ENV VERSION=${VERSION}
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile
COPY ui/package.json ui/bun.lock ./ui/
RUN --mount=type=cache,target=/root/.bun/install/cache \
cd ui && bun install --frozen-lockfile
COPY . .
RUN if [ -z "$VERSION" ]; then VERSION=$(bun -p "require('./package.json').version"); fi; \
bun run bump "$VERSION"
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun run build:node
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./
ENV MT_HOST=0.0.0.0 \
MT_PORT=8989 \
NODE_ENV=production
EXPOSE 8989
CMD ["node", "main.js"]

110
src/mtran/vendor/mtranserver/HISTORY.md vendored Normal file
View File

@@ -0,0 +1,110 @@
## v4.0.31
- 添加中文输出标点全角化功能,默认开启
## v4.0.30
- 默认沉浸式翻译接口输入语言为 auto因为它经常输入错误的语言代码只能这样适配它了
- 改进 auto 的混合语言处理逻辑
## v4.0.29
- UI新增跟随系统主题功能
- UI使用 IndexDB 存储历史记录,提升性能
## v4.0.28
- 桌面端:托盘菜单添加开机启动开关
- 桌面端:检测更新功能
## v4.0.27
- 添加 DeepLX 兼容接口
- 增大断句长度,提升了翻译质量,建议升级
- 修复译文标点符号格式错误的问题
## v4.0.20
- 发布到 npm现在可以通过 `npm i -g mtranserver` 安装或者 `npx mtranserver` 来运行服务器啦!
- 修复语言检测的内存安全问题
- 修复混合语言检测的逻辑问题,现在能够正常翻译混合语言文本为目标语言了
- 新增 `--download` 命令,支持通过命令行批量下载语言对模型 (例如 `mtranserver --download en_zh zh_en`)
- 新增 `--languages` 命令,列出所有可下载的语言对
- UI新增宽屏模式按钮
- UI新增多面板并排翻译的功能
- UI新增记忆语言、主题等功能开关的功能
- UI新增副标题文档地址按钮
- UI修复历史记录没有滚动条的问题
## v4.0.20
- 发布到 npm现在可以通过 `npm i -g mtranserver` 安装或者 `npx mtranserver` 来运行服务器啦!
- 修复语言检测的内存安全问题
- 修复混合语言检测的逻辑问题,现在能够正常翻译混合语言文本为目标语言了
- 新增 `--download` 命令,支持通过命令行批量下载语言对模型 (例如 `mtranserver --download en_zh zh_en`)
- 新增 `--languages` 命令,列出所有可下载的语言对
- UI新增宽屏模式按钮
- UI新增多面板并排翻译的功能
- UI新增记忆语言、主题等功能开关的功能
- UI新增副标题文档地址按钮
- UI修复历史记录没有滚动条的问题
## v4.0.13
- 改进 Docker 镜像构建支持,现在任何旧设备都能运行 Docker 版本啦!
- 无论新旧设备,使用 Docker 版本性能更佳!推荐使用 Docker 版本!
- Release 构建的可执行文件暂未跟进该功能,敬请期待!
## v4.0.12
- 改进日志功能 (感谢 @ApliNi)
- 新增 LRU 缓存功能 (感谢 @ApliNi)
## v4.0.11
- 修复认证功能失效的问题
- Fix authentication issue
## v4.0.10
### 中文版本
#### 性能与引擎
- 引擎重构:完成 v4 引擎重构,显著提升运行速度与稳定性。
- 内存优化:内存占用回归至 1GB 以内水平。在 Linux x64 环境下翻译《福尔摩斯探案集》时btop 显示内存占用低于 600MB。
#### 部署与兼容性
- Docker 修复与支持:修复了 Docker 构建问题新增标准版xxnuo/mtranserver:latest与兼容版xxnuo/mtranserver:legacy镜像。
- 多环境支持:新增对旧款 CPU (non-AVX2) 以及 Linux musl 的构建支持。
#### 新功能
- 更新检查器:新增启动时自动检查更新功能。可通过 --check-update 参数或 MT_CHECK_UPDATE 环境变量启用或禁用。
#### 已知问题
- Android 兼容性:当前版本暂时无法在 Android 设备上运行。
---
### English Version
#### Performance & Engine
- Engine Rewrite: The v4 engine has been refactored for significantly faster performance and enhanced stability.
- Memory Efficiency: Memory usage has returned to sub-1GB levels. (Tested on Linux x64 during English-to-Chinese translation of "The Adventures of Sherlock Holmes", btop usage was under 600MB).
#### Deployment & Compatibility
- Docker Improvements: Fixed Docker build issues and added support for both standard (xxnuo/mtranserver:latest) and legacy-compatible (xxnuo/mtranserver:legacy) images.
- Platform Support: Added legacy build support for non-AVX2 CPUs and Linux musl build support.
#### New Features
- Update Checker: Added automatic update checks on startup. This can be toggled via the --check-update flag or the MT_CHECK_UPDATE environment variable.
#### Known Issues
- Android Support: Temporarily unavailable on Android devices.

201
src/mtran/vendor/mtranserver/LICENSE vendored Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,14 @@
# MTranServer Source Provenance
Source repository: https://github.com/xxnuo/MTranServer.git
Source commit: f5672a9
Release: MTranServer 4.0.33
License: Apache-2.0 (see `LICENSE`)
Build entry point: package.json script `build` -> `scripts/build.ts --single`
This directory is vendored, read-only build input. Generated files belong in
`../../.build/mtranserver`, never in this source tree.

214
src/mtran/vendor/mtranserver/README.md vendored Normal file
View File

@@ -0,0 +1,214 @@
# MTranServer
[中文](README.md) | [English](docs/README_en.md) | [日本語](docs/README_ja.md) | [Français](docs/README_fr.md) | [Deutsch](docs/README_de.md)
<!-- <img src="./images/icon.png" width="64px" height="64px" align="right" alt="MTran"> -->
一个超低资源消耗速度超快的离线翻译模型服务器,无需显卡。单个请求平均响应时间 50 毫秒。支持全世界主要语言的翻译。
注意本模型服务器专注于`离线翻译``响应速度``跨平台部署``本地运行` 达到 `无限免费翻译` 的设计目标,受限于模型大小和优化程度,所以翻译质量肯定是不如大模型翻译的效果。需要高质量的翻译建议使用在线大模型 API。
> v4 优化了内存占用,速度进一步提升,增强了稳定性,如果你在使用旧版建议立即升级!
<img src="./images/preview.png" width="auto" height="460">
## 在线试用 Demo
| 网站 | TOKEN | 其他接口 | 提供者 |
| ---------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------ |
| [ipacel.cc](https://MTranServer.ipacel.cc/ui/?token=__IpacEL_MT_API_TOKEN__) | `__IpacEL_MT_API_TOKEN__` | 沉浸式翻译: `https://MTranServer.ipacel.cc/imme?token=__IpacEL_MT_API_TOKEN__` | [@ApliNi](https://github.com/ApliNi) |
感谢社区贡献者为用户提供试用服务!
## 使用说明
现在支持桌面端一键启动!支持 Windows、Mac、Linux。
### 桌面端
#### 手动下载
前往 [Releases](https://github.com/xxnuo/MTranServer/releases) 下载对应平台最新桌面端,直接安装启动,即可使用。
桌面端启动后会创建一个托盘菜单,通过菜单可以方便的管理服务。
程序自带的一个简单 UI 的地址和在线调试文档。
具体使用说明可以直接跳转到 [生态项目](#生态项目)
预览(最新版有更新):
![UI](./images/ui.png)
![文档](./images/swagger.png)
### 服务端
推荐使用桌面端或者 Docker 部署,性能更佳使用方便。服务端手动部署供专业用户使用。
#### 快速开始
程序员朋友可以通过命令行直接启动服务器端:
```bash
npx mtranserver@latest
```
> `npx` 可以替换为你喜欢的任意一个包管理器,比如 `bunx`、`pnpx` 等。
> **重要提示:**
>
> 首次翻译某个语言对时,服务器会自动下载对应的翻译模型(除非启用了离线模式),这个过程可能需要等待一段时间(取决于网络速度和模型大小)。
> 模型下载完成后,翻译请求将享受毫秒级的响应速度。建议在正式使用前先测试一次翻译,让服务器预先下载和加载模型。程序经常更新,如果遇到问题,可以尝试更新到最新版本。
#### 快速安装
```bash
npm i -g mtranserver@latest
```
> `npm` 可以替换为你喜欢的任意一个包管理器,比如 `bun`、`pnpm` 等。
然后启动 `mtranserver` 即可。
#### Docker Compose 部署
找一个空目录,编写 `compose.yml` 文件,内容如下:
```yml
services:
mtranserver:
image: xxnuo/mtranserver:latest
container_name: mtranserver
restart: unless-stopped
ports:
- "8989:8989"
environment:
- MT_HOST=0.0.0.0
- MT_PORT=8989
- MT_OFFLINE=false
# - MT_API_TOKEN=your_secret_token_here
volumes:
- ./models:/app/models
```
```bash
docker pull xxnuo/mtranserver:latest
docker compose up -d
```
## 生态项目
### IDE 插件
#### [MTranCode](https://github.com/xxnuo/MTranCode) 代码注释翻译插件
支持 VS Code、Cursor、Augment 等 VS Code 系列 IDE
在插件商店搜索 **`MTranCode`** 即可安装注释翻译插件
插件默认接口会调用 `http://localhost:8989` 接口的服务器进行注释、代码的翻译,可在设置中调整。
该插件由 [vscode-comment-translate](https://github.com/intellism/vscode-comment-translate) fork 而来。
### 浏览器插件
#### [MTranBrowser](https://github.com/xxnuo/MTranBrowser)
TODO: 火热开发中
> 如果你开发了衍生项目,欢迎提交 PR我会在生态项目中添加你的项目。
> 对了项目已经发布到 npm 包,可以直接在其他程序中调用简单的库接口实现翻译功能,具体信息查看 ts 类型说明。
## 兼容接口
服务器提供了多个翻译插件的兼容接口:
| 接口 | 方法 | 说明 | 支持的插件 |
| ------------------------------- | ---- | ---------------------------------- | --------------------------------------------------------------------------- |
| `/imme` | POST | 沉浸式翻译插件接口 | [沉浸式翻译](https://immersivetranslate.com/) |
| `/kiss` | POST | 简约翻译插件接口 | [简约翻译](https://github.com/fishjar/kiss-translator) |
| `/deepl` | POST | DeepL API v2 兼容接口 | 支持 DeepL API 的客户端 |
| `/deeplx` | POST | DeepLX 兼容接口 | 支持 DeepLX 接口的客户端 |
| `/hcfy` | POST | 划词翻译兼容接口 | [划词翻译](https://github.com/Selection-Translator/crx-selection-translate) |
| `/hcfy` | POST | 划词翻译兼容接口 | [划词翻译](https://github.com/Selection-Translator/crx-selection-translate) |
| `/google/language/translate/v2` | POST | Google Translate API v2 兼容接口 | 支持 Google Translate API 的客户端 |
| `/google/translate_a/single` | GET | Google translate_a/single 兼容接口 | 支持 Google 网页翻译的客户端 |
**插件配置说明:**
> 注:
>
> - [沉浸式翻译](https://immersivetranslate.com/zh-Hans/docs/services/custom/) 在`设置`页面,开发者模式中启用`Beta`特性,即可在`翻译服务`中看到`自定义 API 设置`([官方图文教程](https://immersivetranslate.com/zh-Hans/docs/services/custom/))。然后将`自定义 API 设置`的`每秒最大请求数`拉高以充分发挥服务器性能准备体验飞一般的感觉。我设置的是`每秒最大请求数`为`512``每次请求最大段落数`为`1`。你可以根据自己服务器配置设置。
>
> - [简约翻译](https://github.com/fishjar/kiss-translator) 在`设置`页面,接口设置中滚动到下面,即可看到自定义接口 `Custom`。同理,设置`最大请求并发数量`、`每次请求间隔时间`以充分发挥服务器性能。我设置的是`最大请求并发数量`为`100``每次请求间隔时间`为`1`。你可以根据自己服务器配置设置。
>
> 接下来按下表的设置方法设置插件的自定义接口地址。
| 名称 | URL | 插件设置 |
| ---------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
| 沉浸式翻译无密码 | `http://localhost:8989/imme` | `自定义API 设置` - `API URL` |
| 沉浸式翻译有密码 | `http://localhost:8989/imme?token=your_token` | 同上,需要更改 URL 尾部的 `your_token` 为你的 `MT_API_TOKEN` 值 |
| 简约翻译无密码 | `http://localhost:8989/kiss` | `接口设置` - `Custom` - `URL` |
| 简约翻译有密码 | `http://localhost:8989/kiss` | 同上,需要 `KEY``your_token` |
| DeepL 兼容 | `http://localhost:8989/deepl` | 使用 `DeepL-Auth-Key``Bearer` 认证 |
| DeepLX 兼容 | `http://localhost:8989/deeplx` | 支持 `token` 参数或 `Bearer` 认证 |
| Google 兼容 | `http://localhost:8989/google/language/translate/v2` | 使用 `key` 参数或 `Bearer` 认证 |
| 划词翻译 | `http://localhost:8989/hcfy` | 支持 `token` 参数或 `Bearer` 认证 |
**普通用户参照表格内容设置好插件使用的接口地址就可以使用了。**
### 命令行参数
```bash
./mtranserver [选项]
选项:
-version, -v 显示版本信息
-log-level string 日志级别 (debug, info, warn, error) (默认 "warn")
-config-dir string 配置目录 (默认 "~/.config/mtran/server")
-model-dir string 模型目录 (默认 "~/.config/mtran/models")
-host string 服务器监听地址 (默认 "0.0.0.0")
-port string 服务器端口 (默认 "8989")
-ui 启用 Web UI (默认 true)
-offline 启用离线模式,不自动下载新模型 (默认 false)
-worker-idle-timeout int Worker 空闲超时时间(秒) (默认 300)
--download pairs... 下载指定语言对的模型 (例如 --download en_zh zh_en)
--languages 列出所有支持下载的语言对
注意:`--download``--languages` 命令需要联网,无法在离线模式下工作。
示例:
./mtranserver --host 127.0.0.1 --port 8080
./mtranserver --ui --offline
./mtranserver -v
```
## 同类项目
列出一些同类功能的项目,如果有其他需求的用户可以尝试这些项目:
| 项目名称 | 内存占用 | 并发性能 | 翻译效果 | 速度 | 其他信息 |
| ------------------------------------------------------------------ | -------- | -------- | -------- | ---- | --------------------------------------------------------------------------------------------------------------------------------- |
| [NLLB](https://github.com/facebookresearch/fairseq/tree/nllb) | 很高 | 差 | 一般 | 慢 | 大佬移植到了 Android 的 [RTranslator](https://github.com/niedev/RTranslator) 有很多优化,但占用仍然高,速度也不快 |
| [LibreTranslate](https://github.com/LibreTranslate/LibreTranslate) | 很高 | 一般 | 一般 | 中等 | 中端 CPU 每秒处理 3 句,高端 CPU 每秒处理 15-20 句,[详情](https://community.libretranslate.com/t/performance-benchmark-data/486) |
| [OPUS-MT](https://github.com/OpenNMT/CTranslate2#benchmarks) | 高 | 一般 | 略差 | 快 | [性能测试](https://github.com/OpenNMT/CTranslate2#benchmarks) |
| 其他大模型 | 超高 | 动态 | 非常好 | 很慢 | 对硬件要求很高,如果需要高并发翻译建议使用 vllm 框架 |
| 本项目 | 低 | 高 | 一般 | 极快 | 单个请求平均响应时间 50ms |
> 表中为 CPU、英译中场景下的简单测试非严格测试非量化版本对比仅供参考。
# 高级配置说明
请参考 [API.md](API.md) 文件和启动后的 API 文档。
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=xxnuo/MTranServer&type=Timeline)](https://www.star-history.com/#xxnuo/MTranServer&Timeline)
## Thanks
[Bergamot Project](https://browser.mt/) for awesome idea of local translation.
[Mozilla](https://github.com/mozilla) for the [models](https://github.com/mozilla/firefox-translations-models).

1149
src/mtran/vendor/mtranserver/bun.lock vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
services:
mtranserver:
image: xxnuo/mtranserver:latest
container_name: mtranserver
restart: unless-stopped
ports:
- "8989:8989"
environment:
- MT_HOST=0.0.0.0
- MT_PORT=8989
- MT_ENABLE_UI=true
- MT_OFFLINE=false
# - MT_API_TOKEN=your_secret_token_here # 取消注释以启用 API 认证
volumes:
- ./models:/app/models # 翻译模型目录
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8989/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s

View File

@@ -0,0 +1,3 @@
# C++ version (deprecated)
Origianl version ( ~ v2.1.1 ) is deprecated, continue to develop in [golang version](https://github.com/xxnuo/MTranServer)

View File

@@ -0,0 +1,7 @@
v3.2.1
- 重构进程守护,解决进程残留问题
- 添加 workers-per-language 参数,支持每个语言启动多个 worker 进程,默认 1
- 降低 worker 空闲超时时间,默认 60 秒
历史更新内容请参考 [HISTORY.md](HISTORY.md)
For historical update content, please refer to [HISTORY.md](HISTORY.md)

View File

@@ -0,0 +1,76 @@
# Build stage
FROM golang:1.25.3-bookworm AS builder
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends git make curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code (UI dist should be pre-built and copied)
COPY . .
# Download resources
RUN make download
# Verify required files exist (should be prepared before docker build)
RUN echo "Verifying build prerequisites..." && \
if [ ! -f "ui/dist/index.html" ]; then \
echo "ERROR: ui/dist/index.html not found!"; \
exit 1; \
fi && \
if [ ! -f "bin/worker" ]; then \
echo "ERROR: bin/worker not found!"; \
exit 1; \
fi && \
if [ ! -f "internal/docs/swagger.json" ]; then \
echo "ERROR: internal/docs/swagger.json not found!"; \
exit 1; \
fi && \
echo "✓ All prerequisites verified"
# Build binary
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/xxnuo/MTranServer/internal/version.Version=${VERSION} -s -w" \
-o mtranserver \
./cmd/mtranserver
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata curl
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/mtranserver /app/mtranserver
# Create directories for data and models
RUN mkdir -p /app/data /app/models
# Expose port
EXPOSE 8989
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8989/health || exit 1
# Set default environment variables
ENV MT_HOST=0.0.0.0 \
MT_PORT=8989 \
MT_LOG_LEVEL=warn \
MT_CONFIG_DIR=/app/data \
MT_MODEL_DIR=/app/models \
MT_ENABLE_UI=true \
MT_OFFLINE=false
# Run the application
CMD ["/app/mtranserver"]

View File

@@ -0,0 +1,71 @@
v3.2.1
- 重构进程守护,解决进程残留问题
- 添加 workers-per-language 参数,支持每个语言启动多个 worker 进程,默认 1
- 降低 worker 空闲超时时间,默认 60 秒
v3.1.23
- 发布稳定版
- Release stable version
v3.1.22
- 增强语言检测:支持混合脚本检测、置信度评分、动态语言支持
- 优化相邻语言段合并逻辑,提升检测准确性
- 添加内存监控功能
- 改进 worker 停止和清理逻辑
- /imme 接口默认启用 isHTML
- 日志支持彩色输出
- Enhance language detection: support mixed script detection, confidence scoring, dynamic language support
- Optimize merging of adjacent language segments for improved detection accuracy
- Add memory monitoring
- Improve worker stop and cleanup logic
- Default enable isHTML for /imme endpoint
- Add color support for logger
v3.1.21
- 修复格式对齐问题
- Fix format alignment issue
v3.1.20
- 添加 worker 版本信息
- Add worker version info
v3.1.19
- 支持检测和翻译文本中的多语言段落
- 实现分段翻译以改进错误处理
- 增强 Compute 方法的连接错误检查
- 移除 emoji 处理(已移至 MTranCore
- 添加服务脚本
- Support detecting and translating multiple language segments in text
- Implement segmented translation for improved error handling
- Enhance connection error checks in Compute method
- Remove emoji handling (moved to MTranCore)
- Add service script
v3.1.18
- 修复网页界面类型问题
- Fix web interface type issue
v3.1.17
- 改进网页界面,添加语言名称国际化
- Improve web interface with language name i18n
v3.1.16
- 改进网页界面:添加语言名称、历史记录、工具按钮等
- Improve the web interface: add language names, history records, tool buttons, etc.
v3.1.15
- 修复下载模型提示校验失败的问题
- Fix the issue of model download verification failure
v3.1.14
- 工作时自动下载使用最新版本的模型,无需手动下载(离线模式则不会更新)
- Auto download the latest version of the model when working, no need to manually download (offline mode will not update)
v3.1.13
- 添加详细日志,方便调试
- Add detailed logs for debugging
v3.1.12
- 修复高并发情况下翻译结果错乱的 Bug
- Fix: High concurrency translation result confusion bug

View File

@@ -0,0 +1,59 @@
.PHONY: build-ui download download-core download-records generate-docs
# Detect OS and architecture
GOOS ?= $(shell go env GOOS)
GOARCH ?= $(shell go env GOARCH)
# Set binary suffix for Windows, js
ifeq ($(GOOS),windows)
SUFFIX := .exe
else ifeq ($(GOOS),js)
SUFFIX := .wasm
else
SUFFIX :=
endif
# GitHub release URL
GITHUB_REPO := xxnuo/MTranCore
RELEASE_TAG := latest
WORKER_BINARY := worker-$(GOOS)-$(GOARCH)$(SUFFIX)
DOWNLOAD_URL := https://github.com/$(GITHUB_REPO)/releases/latest/download/$(WORKER_BINARY)
# Download core binary from https://github.com/xxnuo/MTranCore/releases/latest
# Support: linux-amd64, linux-arm64, linux-386, windows-amd64, darwin-amd64, darwin-arm64
# Extra: js-wasm
download-core:
touch ./bin/worker
@TARGET_GOOS=$(GOOS) TARGET_GOARCH=$(GOARCH) GOOS= GOARCH= go generate ./bin
@echo "Downloaded core binary from repository successfully"
download-records:
touch ./data/records.json
@GOOS= GOARCH= go generate ./data
@echo "Generated records hash successfully"
download: download-core download-records
@echo "Downloaded successfully"
generate-docs:
@echo "Generating docs..."
@GOOS= GOARCH= go run github.com/swaggo/swag/cmd/swag@latest init -d ./cmd/mtranserver,./internal/handlers,./internal/models -g main.go -o ./internal/docs
@echo "Docs generated successfully"
build-ui:
@echo "Building UI..."
@cd ui && pnpm install && pnpm build
@echo "UI built successfully"
# Get version from git tag
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev")
LDFLAGS := -X github.com/xxnuo/MTranServer/internal/version.Version=$(VERSION)
build: generate-docs
@echo "Building version $(VERSION)..."
@go build -ldflags "$(LDFLAGS)" -o ./dist/mtranserver-$(GOOS)-$(GOARCH)$(SUFFIX) ./cmd/mtranserver
@echo "Built successfully"
dev: generate-docs
@echo "Building version $(VERSION)..."
MT_LOG_LEVEL=debug MT_ENABLE_UI=true go run -ldflags "$(LDFLAGS)" ./cmd/mtranserver/main.go

View File

@@ -0,0 +1,3 @@
# Golang version
The WASM runtime and glue code will panic in high concurrency, so it is temporarily abandoned, and the Node.js version is used.

View File

@@ -0,0 +1,11 @@
package bin
import (
_ "embed"
)
//go:embed worker
var WorkerBinary []byte
//go:embed worker.version
var WorkerVersion string

View File

@@ -0,0 +1,4 @@
// Code generated by go generate; DO NOT EDIT.
package bin
const WorkerHash = "51d403e9477bad177fb74eaa4a936b40fb16697eb702e8a8c3b7157e896f829a"

View File

@@ -0,0 +1,42 @@
//go:build ignore
package main
import (
"crypto/sha256"
"encoding/hex"
"log"
"os"
"text/template"
)
const OutputPath = "bin_hash.go"
const Template = `// Code generated by go generate; DO NOT EDIT.
package bin
const WorkerHash = "{{.Hash}}"
`
func main() {
data, err := os.ReadFile("worker")
if err != nil {
log.Fatalf("Failed to read worker file: %v", err)
}
hashBytes := sha256.Sum256(data)
hashString := hex.EncodeToString(hashBytes[:])
f, err := os.Create(OutputPath)
if err != nil {
log.Fatalf("Failed to create output file: %v", err)
}
defer f.Close()
t := template.Must(template.New("hash").Parse(Template))
err = t.Execute(f, struct{ Hash string }{Hash: hashString})
if err != nil {
log.Fatalf("Failed to execute template: %v", err)
}
log.Printf("Successfully generated %s, WorkerHash: %s", OutputPath, hashString)
}

View File

@@ -0,0 +1,113 @@
//go:build ignore
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"runtime"
"strings"
"github.com/xxnuo/MTranServer/internal/downloader"
)
const (
GithubRepo = "xxnuo/MTranCore"
ReleaseTag = "latest"
)
func getLatestVersion() (string, error) {
url := fmt.Sprintf("https://github.com/%s/releases/%s", GithubRepo, ReleaseTag)
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
location := resp.Header.Get("Location")
if location == "" {
return "", fmt.Errorf("no location header found")
}
parts := strings.Split(location, "/")
if len(parts) < 2 {
return "", fmt.Errorf("invalid location format: %s", location)
}
version := parts[len(parts)-1]
return version, nil
}
func main() {
goos := os.Getenv("TARGET_GOOS")
goarch := os.Getenv("TARGET_GOARCH")
if goos == "" {
goos = runtime.GOOS
}
if goarch == "" {
goarch = runtime.GOARCH
}
var suffix string
switch goos {
case "windows":
suffix = ".exe"
case "js":
suffix = ".wasm"
default:
suffix = ""
}
version, err := getLatestVersion()
if err != nil {
log.Fatalf("Failed to get latest version: %v", err)
}
log.Printf("Latest MTranCore version: %s", version)
workerBinary := fmt.Sprintf("worker-%s-%s%s", goos, goarch, suffix)
downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", GithubRepo, version, workerBinary)
log.Printf("Detecting platform: %s-%s", goos, goarch)
log.Printf("Downloading %s from %s...", workerBinary, downloadURL)
if err := os.MkdirAll(".", 0755); err != nil {
log.Fatalf("Failed to create bin directory: %v", err)
}
targetFile := "worker"
os.Remove(targetFile)
d := downloader.New(".")
if err := d.Download(downloadURL, targetFile, &downloader.DownloadOptions{
Context: context.Background(),
Overwrite: true,
}); err != nil {
log.Fatalf("Failed to download worker binary: %v", err)
}
if err := os.Chmod(targetFile, 0755); err != nil {
log.Printf("Warning: Failed to set executable permission: %v", err)
}
log.Printf("Downloaded successfully to %s", targetFile)
versionFile := "worker.version"
if err := os.WriteFile(versionFile, []byte(version), 0644); err != nil {
log.Fatalf("Failed to write version file: %v", err)
}
log.Printf("Version %s written to %s", version, versionFile)
}

View File

@@ -0,0 +1,14 @@
package bin
import "crypto/sha256"
//go:generate go run gen_update.go
//go:generate go run gen_hash.go
func GetWorkerInfo() (hash string, size int) {
return WorkerHash, len(WorkerBinary)
}
func ComputeHash(data []byte) [32]byte {
return sha256.Sum256(data)
}

View File

@@ -0,0 +1 @@
v3.2.1

View File

@@ -0,0 +1,82 @@
package main
import (
"flag"
"fmt"
"os"
"github.com/xxnuo/MTranServer/internal/config"
"github.com/xxnuo/MTranServer/internal/logger"
"github.com/xxnuo/MTranServer/internal/server"
"github.com/xxnuo/MTranServer/internal/services"
"github.com/xxnuo/MTranServer/internal/version"
)
// @title MTranServer API
// @version 3.0.0
// @description 超低资源消耗超快的离线翻译服务器 API
// @termsOfService https://github.com/xxnuo/MTranServer
// @contact.name API Support
// @contact.url https://github.com/xxnuo/MTranServer/issues
// @license.name Apache 2.0
// @license.url https://github.com/xxnuo/MTranServer/blob/main/LICENSE
// @host localhost:8989
// @BasePath /
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
// @securityDefinitions.apikey ApiKeyQuery
// @in query
// @name token
func main() {
versionFlag := flag.Bool("version", false, "Show version information")
versionShortFlag := flag.Bool("v", false, "Show version information (shorthand)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "MTranServer %s - Ultra-low resource consumption, ultra-fast offline translation server\n\n", version.GetVersion())
fmt.Fprintf(os.Stderr, "Usage:\n")
fmt.Fprintf(os.Stderr, " %s [options]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nEnvironment Variables:\n")
fmt.Fprintf(os.Stderr, " MT_LOG_LEVEL Log level (debug, info, warn, error)\n")
fmt.Fprintf(os.Stderr, " MT_CONFIG_DIR Configuration directory\n")
fmt.Fprintf(os.Stderr, " MT_MODEL_DIR Model directory\n")
fmt.Fprintf(os.Stderr, " MT_HOST Server host address\n")
fmt.Fprintf(os.Stderr, " MT_PORT Server port\n")
fmt.Fprintf(os.Stderr, " MT_ENABLE_UI Enable Web UI (true/false)\n")
fmt.Fprintf(os.Stderr, " MT_OFFLINE Enable offline mode (true/false)\n")
fmt.Fprintf(os.Stderr, " MT_WORKER_IDLE_TIMEOUT Worker idle timeout in seconds\n")
fmt.Fprintf(os.Stderr, " MT_API_TOKEN API access token\n")
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s --host 127.0.0.1 --port 8080\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --ui --offline\n", os.Args[0])
fmt.Fprintf(os.Stderr, " MT_PORT=9000 %s\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nMore information: https://github.com/xxnuo/MTranServer\n")
}
cfg := config.GetConfig()
flag.Parse()
logger.SetLevel(cfg.LogLevel)
if *versionFlag || *versionShortFlag {
fmt.Printf("MTranServer %s\n", version.GetVersion())
fmt.Printf("MTranCore v%s\n", version.GetWorkerVersion())
os.Exit(0)
}
if err := server.Run(); err != nil {
logger.Fatal("Server error: %v", err)
}
services.CleanupAllEngines()
}

View File

@@ -0,0 +1,8 @@
package data
import (
_ "embed"
)
//go:embed records.json
var RecordsJson []byte

View File

@@ -0,0 +1,4 @@
// Code generated by go generate; DO NOT EDIT.
package data
const RecordsHash = "3b6ec5c8035fadd7feffe109319a8d5a874f56035bbde884ccfdb5e8b00237f5"

View File

@@ -0,0 +1,42 @@
//go:build ignore
package main
import (
"crypto/sha256"
"encoding/hex"
"log"
"os"
"text/template"
)
const OutputPath = "data_hash.go"
const Template = `// Code generated by go generate; DO NOT EDIT.
package data
const RecordsHash = "{{.Hash}}"
`
func main() {
data, err := os.ReadFile("records.json")
if err != nil {
log.Fatalf("Failed to read records.json file: %v", err)
}
hashBytes := sha256.Sum256(data)
hashString := hex.EncodeToString(hashBytes[:])
f, err := os.Create(OutputPath)
if err != nil {
log.Fatalf("Failed to create output file: %v", err)
}
defer f.Close()
t := template.Must(template.New("hash").Parse(Template))
err = t.Execute(f, struct{ Hash string }{Hash: hashString})
if err != nil {
log.Fatalf("Failed to execute template: %v", err)
}
log.Printf("Successfully generated %s, RecordsHash: %s", OutputPath, hashString)
}

View File

@@ -0,0 +1,26 @@
//go:build ignore
package main
import (
"context"
"log"
"github.com/xxnuo/MTranServer/internal/downloader"
"github.com/xxnuo/MTranServer/internal/models"
)
func main() {
log.Printf("Downloading records.json from %s...", models.RecordsUrl)
d := downloader.New(".")
err := d.Download(models.RecordsUrl, models.RecordsFileName, &downloader.DownloadOptions{
Context: context.Background(),
Overwrite: true,
})
if err != nil {
log.Fatalf("Failed to download records.json: %v", err)
}
log.Printf("Successfully downloaded to %s", models.RecordsFileName)
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
package data
import "crypto/sha256"
//go:generate go run gen_update.go
//go:generate go run gen_hash.go
func GetRecordsInfo() (hash string, size int) {
return RecordsHash, len(RecordsJson)
}
func ComputeHash(data []byte) [32]byte {
return sha256.Sum256(data)
}

View File

@@ -0,0 +1,116 @@
module github.com/xxnuo/MTranServer
go 1.25.3
require (
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-getter v1.8.2
github.com/klauspost/compress v1.18.2
github.com/pemistahl/lingua-go v1.4.0
github.com/shirou/gopsutil/v4 v4.25.11
github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
)
require (
cloud.google.com/go v0.110.0 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
cloud.google.com/go/iam v0.13.0 // indirect
cloud.google.com/go/storage v1.29.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.15 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.68 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/googleapis/gax-go/v2 v2.7.1 // indirect
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.56.0 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.114.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.56.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,362 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/iam v0.13.0 h1:+CmB+K0J/33d0zSQ9SlFWUeCCEn5XJA0ZMZ3pHE9u8k=
cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0=
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/config v1.29.15 h1:I5XjesVMpDZXZEZonVfjI12VNMrYa38LtLnw4NtY5Ss=
github.com/aws/aws-sdk-go-v2/config v1.29.15/go.mod h1:tNIp4JIPonlsgaO5hxO372a6gjhN63aSWl2GVl5QoBQ=
github.com/aws/aws-sdk-go-v2/credentials v1.17.68 h1:cFb9yjI02/sWHBSYXAtkamjzCuRymvmeFmt0TC0MbYY=
github.com/aws/aws-sdk-go-v2/credentials v1.17.68/go.mod h1:H6E+jBzyqUu8u0vGaU6POkK3P0NylYEeRZ6ynBpMqIk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 h1:BCG7DCXEXpNCcpwCxg1oi9pkJWH2+eZzTn9MY56MbVw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.1 h1:xYEAf/6QHiTZDccKnPMbsMwlau13GsDsTgdue3wmHGw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.1/go.mod h1:qbn305Je/IofWBJ4bJz/Q7pDEtnnoInw/dGt71v6rHE=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 h1:oIaQ1e17CSKaWmUTu62MtraRWVIosn/iONMuZt0gbqc=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.20/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/gax-go/v2 v2.7.1 h1:gF4c0zjUP2H/s/hEGyLA3I0fA2ZWjzYiONAD6cvPr8A=
github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65 h1:81+kWbE1yErFBMjME0I5k3x3kojjKsWtPYHEAutoPow=
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65/go.mod h1:WtMzv9T++tfWVea+qB2MXoaqxw33S8bpJslzUike2mQ=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-getter v1.8.2 h1:CGCK+bZQLl44PYiwJweVzfpjg7bBwtuXu3AGcLiod2o=
github.com/hashicorp/go-getter v1.8.2/go.mod h1:CUTt9x2bCtJ/sV8ihgrITL3IUE+0BE1j/e4n5P/GIM4=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.114.0 h1:1xQPji6cO2E2vLiI+C/XiFAnsn1WV3mjaEwGLhi3grE=
google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc=
google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -0,0 +1,57 @@
package config
import (
"flag"
"os"
"path/filepath"
"github.com/xxnuo/MTranServer/internal/utils"
)
type Config struct {
LogLevel string
HomeDir string
ConfigDir string
ModelDir string
Host string
Port string
EnableWebUI bool
EnableOfflineMode bool
WorkerIdleTimeout int
WorkersPerLanguage int
APIToken string
}
var (
GlobalConfig *Config = nil
)
// GetConfig 加载配置,优先级:命令行参数 > 环境变量 > 默认值
func GetConfig() *Config {
if GlobalConfig != nil {
return GlobalConfig
}
cfg := &Config{}
homeDir, err := os.UserHomeDir()
if err != nil {
panic(err)
}
cfg.HomeDir = filepath.Join(homeDir, ".config", "mtran")
cfg.ConfigDir = filepath.Join(cfg.HomeDir, "server")
cfg.ModelDir = filepath.Join(cfg.HomeDir, "models")
flag.StringVar(&cfg.LogLevel, "log-level", utils.GetEnv("MT_LOG_LEVEL", "warn"), "Log level (debug, info, warn, error)")
flag.StringVar(&cfg.ConfigDir, "config-dir", utils.GetEnv("MT_CONFIG_DIR", cfg.ConfigDir), "Config directory")
flag.StringVar(&cfg.ModelDir, "model-dir", utils.GetEnv("MT_MODEL_DIR", cfg.ModelDir), "Model directory")
flag.StringVar(&cfg.Host, "host", utils.GetEnv("MT_HOST", "0.0.0.0"), "Server host address")
flag.StringVar(&cfg.Port, "port", utils.GetEnv("MT_PORT", "8989"), "Server port")
flag.BoolVar(&cfg.EnableWebUI, "ui", utils.GetBoolEnv("MT_ENABLE_UI", true), "Enable web UI")
flag.BoolVar(&cfg.EnableOfflineMode, "offline", utils.GetBoolEnv("MT_OFFLINE", false), "Enable offline mode")
flag.IntVar(&cfg.WorkerIdleTimeout, "worker-idle-timeout", utils.GetIntEnv("MT_WORKER_IDLE_TIMEOUT", 60), "Worker idle timeout in seconds")
flag.IntVar(&cfg.WorkersPerLanguage, "workers-per-language", utils.GetIntEnv("MT_WORKERS_PER_LANGUAGE", 1), "Number of workers per language pair")
flag.StringVar(&cfg.APIToken, "api-token", utils.GetEnv("MT_API_TOKEN", ""), "API access token")
GlobalConfig = cfg
return cfg
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,773 @@
basePath: /
definitions:
handlers.DeeplTranslateRequest:
properties:
context:
type: string
enable_beta_languages:
type: boolean
formality:
example: default
type: string
glossary_id:
type: string
ignore_tags:
items:
type: string
type: array
model_type:
example: quality_optimized
type: string
non_splitting_tags:
items:
type: string
type: array
outline_detection:
example: "1"
type: string
preserve_formatting:
example: "0"
type: string
source_lang:
example: EN
type: string
split_sentences:
example: "1"
type: string
splitting_tags:
items:
type: string
type: array
tag_handling:
example: xml
type: string
target_lang:
example: DE
type: string
text:
example:
- Hello
- ' world!'
items:
type: string
type: array
required:
- target_lang
- text
type: object
handlers.DeeplTranslateResponse:
properties:
translations:
items:
$ref: '#/definitions/handlers.DeeplTranslation'
type: array
type: object
handlers.DeeplTranslation:
properties:
detected_source_language:
example: EN
type: string
text:
example: Hallo, Welt!
type: string
type: object
handlers.GoogleTranslateRequest:
properties:
format:
example: text
type: string
q:
example: The Great Pyramid of Giza
type: string
source:
example: en
type: string
target:
example: zh-Hans
type: string
required:
- q
- source
- target
type: object
handlers.GoogleTranslateResponse:
properties:
data:
properties:
translations:
items:
properties:
translatedText:
example: 吉萨大金字塔
type: string
type: object
type: array
type: object
type: object
handlers.HcfyDict:
properties:
pos:
example: n.
type: string
terms:
example:
- 你好
- 问候
items:
type: string
type: array
type: object
handlers.HcfyPhonetic:
properties:
name:
example:
type: string
ttsURI:
example: https://...
type: string
value:
example: ˈloʊ
type: string
type: object
handlers.HcfyTranslateRequest:
properties:
destination:
example:
- 中文(简体)
- 英语
items:
type: string
type: array
name:
example: 翻译一
type: string
source:
example: 英语
type: string
text:
example: Hello, word translation.
type: string
required:
- destination
- name
- text
type: object
handlers.HcfyTranslateResponse:
properties:
dict:
items:
$ref: '#/definitions/handlers.HcfyDict'
type: array
from:
example: 英语
type: string
link:
example: https://...
type: string
phonetic:
items:
$ref: '#/definitions/handlers.HcfyPhonetic'
type: array
result:
example:
- 你好,划词翻译。
items:
type: string
type: array
text:
example: Hello, word translation.
type: string
to:
example: 中文(简体)
type: string
ttsURI:
example: https://...
type: string
type: object
handlers.ImmeTranslateRequest:
properties:
source_lang:
example: en
type: string
target_lang:
example: zh-CN
type: string
text_list:
example:
- Hello
- ' world!'
- Good morning!
items:
type: string
type: array
required:
- source_lang
- target_lang
- text_list
type: object
handlers.ImmeTranslateResponse:
properties:
translations:
items:
$ref: '#/definitions/handlers.ImmeTranslation'
type: array
type: object
handlers.ImmeTranslation:
properties:
detected_source_lang:
example: en
type: string
text:
example: 你好,世界!
type: string
type: object
handlers.KissTranslateRequest:
properties:
from:
example: en
type: string
text:
example: Hello, world!
type: string
to:
example: zh-CN
type: string
required:
- from
- text
- to
type: object
handlers.KissTranslateResponse:
properties:
src:
example: en
type: string
text:
example: 你好,世界!
type: string
type: object
handlers.TranslateBatchRequest:
properties:
from:
example: en
type: string
html:
example: false
type: boolean
texts:
example:
- Hello
- ' world!'
- Good morning!
items:
type: string
type: array
to:
example: zh-Hans
type: string
required:
- from
- texts
- to
type: object
handlers.TranslateBatchResponse:
properties:
results:
example:
- 你好,世界!
- 早上好!
items:
type: string
type: array
type: object
handlers.TranslateRequest:
properties:
from:
example: en
type: string
html:
example: false
type: boolean
text:
example: Hello, world!
type: string
to:
example: zh-Hans
type: string
required:
- from
- text
- to
type: object
handlers.TranslateResponse:
properties:
result:
example: 你好,世界!
type: string
type: object
host: localhost:8989
info:
contact:
name: API Support
url: https://github.com/xxnuo/MTranServer/issues
description: 超低资源消耗超快的离线翻译服务器 API
license:
name: Apache 2.0
url: https://github.com/xxnuo/MTranServer/blob/main/LICENSE
termsOfService: https://github.com/xxnuo/MTranServer
title: MTranServer API
version: 3.0.0
paths:
/__heartbeat__:
get:
description: 返回服务状态
produces:
- text/plain
responses:
"200":
description: Ready
schema:
type: string
summary: 心跳检查
tags:
- 系统
/__lbheartbeat__:
get:
description: 返回负载均衡器心跳状态
produces:
- text/plain
responses:
"200":
description: Ready
schema:
type: string
summary: 负载均衡心跳检查
tags:
- 系统
/deepl:
post:
consumes:
- application/json
description: 兼容 DeepL API v2 的翻译接口
parameters:
- description: API Token
in: query
name: token
type: string
- description: DeepL 翻译请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/handlers.DeeplTranslateRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.DeeplTranslateResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: DeepL 翻译兼容接口
tags:
- 插件
/google/language/translate/v2:
post:
consumes:
- application/json
description: 兼容 Google Translate API v2 的翻译接口
parameters:
- description: API Key
in: query
name: key
type: string
- description: Google 翻译请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/handlers.GoogleTranslateRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.GoogleTranslateResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Google 翻译兼容接口
tags:
- 插件
/google/translate_a/single:
get:
consumes:
- application/json
description: 兼容 Google translate_a/single 的翻译接口
parameters:
- default: gtx
description: 客户端类型
in: query
name: client
type: string
- description: 源语言代码
example: en
in: query
name: sl
required: true
type: string
- description: 目标语言代码
example: zh-CN
in: query
name: tl
required: true
type: string
- default: t
description: 数据类型
in: query
name: dt
type: string
- description: 待翻译文本
example: Hello, world!
in: query
name: q
required: true
type: string
- description: API Key
in: query
name: key
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items: {}
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Google translate_a/single 兼容接口
tags:
- 插件
/hcfy:
post:
consumes:
- application/json
description: 兼容划词翻译自定义翻译源接口
parameters:
- description: API Token
in: query
name: token
type: string
- description: 划词翻译请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/handlers.HcfyTranslateRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.HcfyTranslateResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: 划词翻译兼容接口
tags:
- 插件
/health:
get:
description: 检查服务是否正常运行
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
summary: 健康检查
tags:
- 系统
/imme:
post:
consumes:
- application/json
description: 为沉浸式翻译插件提供的翻译接口
parameters:
- description: API Token
in: query
name: token
type: string
- description: 沉浸式翻译请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/handlers.ImmeTranslateRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.ImmeTranslateResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: 沉浸式翻译插件接口
tags:
- 插件
/kiss:
post:
consumes:
- application/json
description: 为简约翻译插件提供的单文本翻译接口
parameters:
- description: API Token
in: header
name: KEY
type: string
- description: 简约翻译请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/handlers.KissTranslateRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.KissTranslateResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: 简约翻译插件接口(非聚合)
tags:
- 插件
/languages:
get:
description: 返回所有支持的翻译语言代码
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
items:
type: string
type: array
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- ApiKeyAuth: []
- ApiKeyQuery: []
summary: 获取支持的语言列表
tags:
- 翻译
/translate:
post:
consumes:
- application/json
description: 翻译单个文本
parameters:
- description: 翻译请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/handlers.TranslateRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.TranslateResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- ApiKeyAuth: []
- ApiKeyQuery: []
summary: 单文本翻译
tags:
- 翻译
/translate/batch:
post:
consumes:
- application/json
description: 批量翻译多个文本
parameters:
- description: 批量翻译请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/handlers.TranslateBatchRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.TranslateBatchResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- ApiKeyAuth: []
- ApiKeyQuery: []
summary: 批量翻译
tags:
- 翻译
/version:
get:
description: 返回当前服务的版本号
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
summary: 获取服务版本
tags:
- 系统
securityDefinitions:
ApiKeyAuth:
in: header
name: Authorization
type: apiKey
ApiKeyQuery:
in: query
name: token
type: apiKey
swagger: "2.0"

View File

@@ -0,0 +1,171 @@
package downloader
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"github.com/hashicorp/go-getter"
"github.com/xxnuo/MTranServer/internal/logger"
"github.com/xxnuo/MTranServer/internal/utils"
)
type Downloader struct {
DestDir string
ProgressFunc getter.ProgressTracker
}
type DownloadOptions struct {
SHA256 string
Overwrite bool
Context context.Context
}
func New(destDir string) *Downloader {
return &Downloader{
DestDir: destDir,
}
}
func (d *Downloader) SetProgressFunc(fn getter.ProgressTracker) {
d.ProgressFunc = fn
}
func (d *Downloader) Download(urlStr, filename string, opts *DownloadOptions) error {
if opts == nil {
opts = &DownloadOptions{
Context: context.Background(),
}
}
if opts.Context == nil {
opts.Context = context.Background()
}
if err := os.MkdirAll(d.DestDir, 0755); err != nil {
return fmt.Errorf("Failed to create directory: %w", err)
}
dst := filepath.Join(d.DestDir, filename)
if !opts.Overwrite {
if _, err := os.Stat(dst); err == nil {
if opts.SHA256 != "" {
if err := utils.VerifySHA256(dst, opts.SHA256); err == nil {
logger.Debug("File %s already exists and verified, skipping download", filename)
return nil
}
}
}
}
logger.Info("Downloading %s from %s", filename, urlStr)
tmpFile := dst + ".tmp"
defer os.Remove(tmpFile)
httpClient := &http.Client{
Timeout: 30 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
},
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}
if proxyURL := os.Getenv("HTTP_PROXY"); proxyURL != "" {
if parsedURL, err := url.Parse(proxyURL); err == nil {
transport.Proxy = http.ProxyURL(parsedURL)
}
} else if proxyURL := os.Getenv("http_proxy"); proxyURL != "" {
if parsedURL, err := url.Parse(proxyURL); err == nil {
transport.Proxy = http.ProxyURL(parsedURL)
}
}
if proxyURL := os.Getenv("HTTPS_PROXY"); proxyURL != "" {
if parsedURL, err := url.Parse(proxyURL); err == nil {
transport.Proxy = http.ProxyURL(parsedURL)
}
} else if proxyURL := os.Getenv("https_proxy"); proxyURL != "" {
if parsedURL, err := url.Parse(proxyURL); err == nil {
transport.Proxy = http.ProxyURL(parsedURL)
}
}
httpClient.Transport = transport
httpGetter := &getter.HttpGetter{
Client: httpClient,
}
clientOpts := []getter.ClientOption{
getter.WithContext(opts.Context),
getter.WithGetters(map[string]getter.Getter{
"http": httpGetter,
"https": httpGetter,
}),
getter.WithDecompressors(map[string]getter.Decompressor{}),
}
if d.ProgressFunc != nil {
clientOpts = append(clientOpts, getter.WithProgress(d.ProgressFunc))
}
client := &getter.Client{
Src: urlStr,
Dst: tmpFile,
Mode: getter.ClientModeFile,
}
if err := client.Configure(clientOpts...); err != nil {
return fmt.Errorf("Failed to configure downloader: %w", err)
}
if err := client.Get(); err != nil {
return fmt.Errorf("Failed to download: %w", err)
}
logger.Debug("Download completed: %s", filename)
if opts.SHA256 != "" {
logger.Debug("Verifying SHA256 for %s", filename)
if err := utils.VerifySHA256(tmpFile, opts.SHA256); err != nil {
return fmt.Errorf("Failed to verify SHA256: %w", err)
}
logger.Debug("SHA256 verification passed for %s", filename)
}
if err := os.Rename(tmpFile, dst); err != nil {
return fmt.Errorf("Failed to move file: %w", err)
}
logger.Info("Successfully downloaded: %s", filename)
return nil
}
func DownloadFile(url, destPath, sha256sum string) error {
dir := filepath.Dir(destPath)
filename := filepath.Base(destPath)
d := New(dir)
return d.Download(url, filename, &DownloadOptions{
SHA256: sha256sum,
Context: context.Background(),
})
}

View File

@@ -0,0 +1,231 @@
package downloader
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestDownload(t *testing.T) {
testContent := []byte("Hello, World!")
expectedSHA256 := "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(testContent)
}))
defer server.Close()
tempDir, err := os.MkdirTemp("", "downloader-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
d := New(tempDir)
err = d.Download(server.URL, "test.txt", &DownloadOptions{
SHA256: expectedSHA256,
Context: context.Background(),
})
if err != nil {
t.Fatalf("下载失败: %v", err)
}
filePath := filepath.Join(tempDir, "test.txt")
if _, err := os.Stat(filePath); os.IsNotExist(err) {
t.Fatal("文件不存在")
}
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatal(err)
}
if string(content) != string(testContent) {
t.Fatalf("文件内容不匹配: 期望 %s, 实际 %s", testContent, content)
}
}
func TestDownloadWithWrongSHA256(t *testing.T) {
testContent := []byte("Hello, World!")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(testContent)
}))
defer server.Close()
tempDir, err := os.MkdirTemp("", "downloader-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
d := New(tempDir)
err = d.Download(server.URL, "test.txt", &DownloadOptions{
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
Context: context.Background(),
})
if err == nil {
t.Fatal("应该返回 SHA256 校验失败错误")
}
}
func TestDownloadSkipExisting(t *testing.T) {
testContent := []byte("Hello, World!")
expectedSHA256 := "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Write(testContent)
}))
defer server.Close()
tempDir, err := os.MkdirTemp("", "downloader-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
d := New(tempDir)
err = d.Download(server.URL, "test.txt", &DownloadOptions{
SHA256: expectedSHA256,
Context: context.Background(),
})
if err != nil {
t.Fatalf("第一次下载失败: %v", err)
}
firstRequestCount := requestCount
err = d.Download(server.URL, "test.txt", &DownloadOptions{
SHA256: expectedSHA256,
Context: context.Background(),
})
if err != nil {
t.Fatalf("第二次下载失败: %v", err)
}
if requestCount != firstRequestCount {
t.Fatalf("期望 %d 次请求(第二次应跳过), 实际 %d 次", firstRequestCount, requestCount)
}
}
func TestDownloadWithOverwrite(t *testing.T) {
testContent := []byte("Hello, World!")
expectedSHA256 := "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Write(testContent)
}))
defer server.Close()
tempDir, err := os.MkdirTemp("", "downloader-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
d := New(tempDir)
err = d.Download(server.URL, "test.txt", &DownloadOptions{
SHA256: expectedSHA256,
Context: context.Background(),
})
if err != nil {
t.Fatalf("第一次下载失败: %v", err)
}
firstRequestCount := requestCount
err = d.Download(server.URL, "test.txt", &DownloadOptions{
SHA256: expectedSHA256,
Overwrite: true,
Context: context.Background(),
})
if err != nil {
t.Fatalf("第二次下载失败: %v", err)
}
if requestCount <= firstRequestCount {
t.Fatalf("期望至少 %d 次请求, 实际 %d 次", firstRequestCount+1, requestCount)
}
}
func TestDownloadWithContext(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Write([]byte("Hello, World!"))
}))
defer server.Close()
tempDir, err := os.MkdirTemp("", "downloader-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
d := New(tempDir)
err = d.Download(server.URL, "test.txt", &DownloadOptions{
Context: ctx,
})
if err == nil {
t.Fatal("应该返回超时错误")
}
}
func TestDownloadFile(t *testing.T) {
testContent := []byte("Hello, World!")
expectedSHA256 := "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(testContent)
}))
defer server.Close()
tempDir, err := os.MkdirTemp("", "downloader-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
destPath := filepath.Join(tempDir, "test.txt")
err = DownloadFile(server.URL, destPath, expectedSHA256)
if err != nil {
t.Fatalf("下载失败: %v", err)
}
if _, err := os.Stat(destPath); os.IsNotExist(err) {
t.Fatal("文件不存在")
}
content, err := os.ReadFile(destPath)
if err != nil {
t.Fatal(err)
}
if string(content) != string(testContent) {
t.Fatalf("文件内容不匹配: 期望 %s, 实际 %s", testContent, content)
}
}

View File

@@ -0,0 +1,141 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/services"
"github.com/xxnuo/MTranServer/internal/utils"
)
var bcp47ToDeeplLang = map[string]string{
"no": "NB",
"zh-Hans": "ZH",
"zh-CN": "ZH-CN",
"zh-Hant": "ZH-TW",
"zh-TW": "ZH-TW",
}
func convertBCP47ToDeeplLang(bcp47Lang string) string {
if deeplLang, ok := bcp47ToDeeplLang[bcp47Lang]; ok {
return deeplLang
}
return strings.ToUpper(bcp47Lang)
}
type DeeplTranslateRequest struct {
Text []string `json:"text" binding:"required" example:"Hello, world!"`
SourceLang string `json:"source_lang,omitempty" example:"EN"`
TargetLang string `json:"target_lang" binding:"required" example:"DE"`
SplitSentences string `json:"split_sentences,omitempty" example:"1"`
PreserveFormatting string `json:"preserve_formatting,omitempty" example:"0"`
Formality string `json:"formality,omitempty" example:"default"`
GlossaryID string `json:"glossary_id,omitempty"`
TagHandling string `json:"tag_handling,omitempty" example:"xml"`
OutlineDetection string `json:"outline_detection,omitempty" example:"1"`
NonSplittingTags []string `json:"non_splitting_tags,omitempty"`
SplittingTags []string `json:"splitting_tags,omitempty"`
IgnoreTags []string `json:"ignore_tags,omitempty"`
ModelType string `json:"model_type,omitempty" example:"quality_optimized"`
Context string `json:"context,omitempty"`
EnableBetaLanguages bool `json:"enable_beta_languages,omitempty"`
}
type DeeplTranslation struct {
DetectedSourceLanguage string `json:"detected_source_language" example:"EN"`
Text string `json:"text" example:"Hallo, Welt!"`
}
type DeeplTranslateResponse struct {
Translations []DeeplTranslation `json:"translations"`
}
// HandleDeeplTranslate DeepL 翻译兼容接口
// @Summary DeepL 翻译兼容接口
// @Description 兼容 DeepL API v2 的翻译接口
// @Tags 插件
// @Accept json
// @Produce json
// @Param token query string false "API Token"
// @Param request body DeeplTranslateRequest true "DeepL 翻译请求"
// @Success 200 {object} DeeplTranslateResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /deepl [post]
func HandleDeeplTranslate(apiToken string) gin.HandlerFunc {
return func(c *gin.Context) {
if apiToken != "" {
authHeader := c.GetHeader("Authorization")
token := ""
if strings.HasPrefix(authHeader, "DeepL-Auth-Key ") {
token = strings.TrimPrefix(authHeader, "DeepL-Auth-Key ")
} else if authHeader != "" {
token = strings.TrimPrefix(authHeader, "Bearer ")
} else {
token = c.Query("token")
}
if token != apiToken {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
return
}
}
var req DeeplTranslateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
sourceLang := "auto"
if req.SourceLang != "" {
sourceLang = utils.NormalizeLanguageCode(req.SourceLang)
}
targetLang := utils.NormalizeLanguageCode(req.TargetLang)
translations := make([]DeeplTranslation, len(req.Text))
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
isHTML := req.TagHandling == "html" || req.TagHandling == "xml"
for i, text := range req.Text {
result, err := services.TranslateWithPivot(ctx, sourceLang, targetLang, text, isHTML)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Translation failed at index %d: %v", i, err),
})
return
}
detectedLang := req.SourceLang
if detectedLang == "" {
detectedLang = convertBCP47ToDeeplLang(sourceLang)
}
translations[i] = DeeplTranslation{
DetectedSourceLanguage: detectedLang,
Text: result,
}
}
c.JSON(http.StatusOK, DeeplTranslateResponse{
Translations: translations,
})
}
}

View File

@@ -0,0 +1,211 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/services"
"github.com/xxnuo/MTranServer/internal/utils"
)
var bcp47ToGoogleLang = map[string]string{
"zh-Hans": "zh-CN",
"zh-Hant": "zh-TW",
}
func convertBCP47ToGoogleLang(bcp47Lang string) string {
if googleLang, ok := bcp47ToGoogleLang[bcp47Lang]; ok {
return googleLang
}
return bcp47Lang
}
type GoogleTranslateRequest struct {
Q string `json:"q" binding:"required" example:"The Great Pyramid of Giza"`
Source string `json:"source" binding:"required" example:"en"`
Target string `json:"target" binding:"required" example:"zh-Hans"`
Format string `json:"format" example:"text"`
}
type GoogleTranslateResponse struct {
Data struct {
Translations []struct {
TranslatedText string `json:"translatedText" example:"吉萨大金字塔"`
} `json:"translations"`
} `json:"data"`
}
// HandleGoogleCompatTranslate Google 翻译兼容接口
// @Summary Google 翻译兼容接口
// @Description 兼容 Google Translate API v2 的翻译接口
// @Tags 插件
// @Accept json
// @Produce json
// @Param key query string false "API Key"
// @Param request body GoogleTranslateRequest true "Google 翻译请求"
// @Success 200 {object} GoogleTranslateResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /google/language/translate/v2 [post]
func HandleGoogleCompatTranslate(apiToken string) gin.HandlerFunc {
return func(c *gin.Context) {
if apiToken != "" {
token := c.Query("key")
if token == "" {
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
} else if authHeader != "" {
token = authHeader
}
}
if token == "" {
token = c.Query("token")
}
if token != apiToken {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
return
}
}
var req GoogleTranslateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
sourceBCP47 := utils.NormalizeLanguageCode(req.Source)
targetBCP47 := utils.NormalizeLanguageCode(req.Target)
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
isHTML := req.Format == "html"
result, err := services.TranslateWithPivot(ctx, sourceBCP47, targetBCP47, req.Q, isHTML)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Translation failed: %v", err),
})
return
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"translations": []gin.H{
{
"translatedText": result,
},
},
},
})
}
}
// HandleGoogleTranslateSingle Google translate_a/single 兼容接口
// @Summary Google translate_a/single 兼容接口
// @Description 兼容 Google translate_a/single 的翻译接口
// @Tags 插件
// @Accept json
// @Produce json
// @Param client query string false "客户端类型" default(gtx)
// @Param sl query string true "源语言代码" example(en)
// @Param tl query string true "目标语言代码" example(zh-CN)
// @Param dt query string false "数据类型" default(t)
// @Param q query string true "待翻译文本" example(Hello, world!)
// @Param key query string false "API Key"
// @Success 200 {array} interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /google/translate_a/single [get]
func HandleGoogleTranslateSingle(apiToken string) gin.HandlerFunc {
return func(c *gin.Context) {
if apiToken != "" {
token := c.Query("key")
if token == "" {
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
} else if authHeader != "" {
token = authHeader
}
}
if token == "" {
token = c.Query("token")
}
if token != apiToken {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
return
}
}
sl := c.Query("sl")
tl := c.Query("tl")
q := c.Query("q")
if tl == "" || q == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Missing required parameters: tl, q",
})
return
}
if sl == "" {
sl = "auto"
}
text := q
sourceBCP47 := utils.NormalizeLanguageCode(sl)
targetBCP47 := utils.NormalizeLanguageCode(tl)
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
result, err := services.TranslateWithPivot(ctx, sourceBCP47, targetBCP47, text, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Translation failed: %v", err),
})
return
}
detectedLang := convertBCP47ToGoogleLang(sourceBCP47)
response := []interface{}{
[]interface{}{
[]interface{}{result, text, nil, nil, 1},
},
nil,
detectedLang,
nil,
nil,
nil,
nil,
[]interface{}{},
}
c.JSON(http.StatusOK, response)
}
}

View File

@@ -0,0 +1,412 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/services"
)
var hcfyLangToBCP47 = map[string]string{
"中文(简体)": "zh-Hans",
"中文(繁体)": "zh-Hant",
"英语": "en",
"日语": "ja",
"韩语": "ko",
"法语": "fr",
"德语": "de",
"西班牙语": "es",
"俄语": "ru",
"意大利语": "it",
"葡萄牙语": "pt",
"阿拉伯语": "ar",
"荷兰语": "nl",
"波兰语": "pl",
"土耳其语": "tr",
"泰语": "th",
"越南语": "vi",
"印尼语": "id",
"马来语": "ms",
"希腊语": "el",
"捷克语": "cs",
"丹麦语": "da",
"芬兰语": "fi",
"匈牙利语": "hu",
"挪威语": "no",
"罗马尼亚语": "ro",
"瑞典语": "sv",
"乌克兰语": "uk",
"保加利亚语": "bg",
"爱沙尼亚语": "et",
"拉脱维亚语": "lv",
"立陶宛语": "lt",
"斯洛伐克语": "sk",
"斯洛文尼亚语": "sl",
"印地语": "hi",
"孟加拉语": "bn",
"旁遮普语": "pa",
"泰米尔语": "ta",
"泰卢固语": "te",
"马拉地语": "mr",
"古吉拉特语": "gu",
"卡纳达语": "kn",
"马拉雅拉姆语": "ml",
"僧伽罗语": "si",
"尼泊尔语": "ne",
"缅甸语": "my",
"高棉语": "km",
"老挝语": "lo",
"波斯语": "fa",
"希伯来语": "he",
"乌尔都语": "ur",
"斯瓦希里语": "sw",
"南非荷兰语": "af",
"冰岛语": "is",
"塞尔维亚语": "sr",
"克罗地亚语": "hr",
"波斯尼亚语": "bs",
"马其顿语": "mk",
"阿尔巴尼亚语": "sq",
"亚美尼亚语": "hy",
"格鲁吉亚语": "ka",
"阿塞拜疆语": "az",
"哈萨克语": "kk",
"乌兹别克语": "uz",
"蒙古语": "mn",
"藏语": "bo",
"维吾尔语": "ug",
"菲律宾语": "fil",
"世界语": "eo",
"拉丁语": "la",
"加泰罗尼亚语": "ca",
"巴斯克语": "eu",
"加利西亚语": "gl",
"威尔士语": "cy",
"爱尔兰语": "ga",
"苏格兰盖尔语": "gd",
"马耳他语": "mt",
"卢森堡语": "lb",
"弗里西语": "fy",
"白俄罗斯语": "be",
"塔吉克语": "tg",
"吉尔吉斯语": "ky",
"土库曼语": "tk",
"普什图语": "ps",
"库尔德语": "ku",
"信德语": "sd",
"宿务语": "ceb",
"伊博语": "ig",
"约鲁巴语": "yo",
"祖鲁语": "zu",
"科萨语": "xh",
"索马里语": "so",
"豪萨语": "ha",
"阿姆哈拉语": "am",
"奥里亚语": "or",
"阿萨姆语": "as",
"迈蒂利语": "mai",
"桑塔利语": "sat",
"梵语": "sa",
"克什米尔语": "ks",
"多格拉语": "doi",
"孔卡尼语": "kok",
"曼尼普尔语": "mni",
"博多语": "brx",
}
var bcp47ToHcfyLang = map[string]string{
"zh-Hans": "中文(简体)",
"zh-CN": "中文(简体)",
"zh-Hant": "中文(繁体)",
"zh-TW": "中文(繁体)",
"en": "英语",
"ja": "日语",
"ko": "韩语",
"fr": "法语",
"de": "德语",
"es": "西班牙语",
"ru": "俄语",
"it": "意大利语",
"pt": "葡萄牙语",
"ar": "阿拉伯语",
"nl": "荷兰语",
"pl": "波兰语",
"tr": "土耳其语",
"th": "泰语",
"vi": "越南语",
"id": "印尼语",
"ms": "马来语",
"el": "希腊语",
"cs": "捷克语",
"da": "丹麦语",
"fi": "芬兰语",
"hu": "匈牙利语",
"no": "挪威语",
"ro": "罗马尼亚语",
"sv": "瑞典语",
"uk": "乌克兰语",
"bg": "保加利亚语",
"et": "爱沙尼亚语",
"lv": "拉脱维亚语",
"lt": "立陶宛语",
"sk": "斯洛伐克语",
"sl": "斯洛文尼亚语",
"hi": "印地语",
"bn": "孟加拉语",
"pa": "旁遮普语",
"ta": "泰米尔语",
"te": "泰卢固语",
"mr": "马拉地语",
"gu": "古吉拉特语",
"kn": "卡纳达语",
"ml": "马拉雅拉姆语",
"si": "僧伽罗语",
"ne": "尼泊尔语",
"my": "缅甸语",
"km": "高棉语",
"lo": "老挝语",
"fa": "波斯语",
"he": "希伯来语",
"ur": "乌尔都语",
"sw": "斯瓦希里语",
"af": "南非荷兰语",
"is": "冰岛语",
"sr": "塞尔维亚语",
"hr": "克罗地亚语",
"bs": "波斯尼亚语",
"mk": "马其顿语",
"sq": "阿尔巴尼亚语",
"hy": "亚美尼亚语",
"ka": "格鲁吉亚语",
"az": "阿塞拜疆语",
"kk": "哈萨克语",
"uz": "乌兹别克语",
"mn": "蒙古语",
"bo": "藏语",
"ug": "维吾尔语",
"fil": "菲律宾语",
"eo": "世界语",
"la": "拉丁语",
"ca": "加泰罗尼亚语",
"eu": "巴斯克语",
"gl": "加利西亚语",
"cy": "威尔士语",
"ga": "爱尔兰语",
"gd": "苏格兰盖尔语",
"mt": "马耳他语",
"lb": "卢森堡语",
"fy": "弗里西语",
"be": "白俄罗斯语",
"tg": "塔吉克语",
"ky": "吉尔吉斯语",
"tk": "土库曼语",
"ps": "普什图语",
"ku": "库尔德语",
"sd": "信德语",
"ceb": "宿务语",
"ig": "伊博语",
"yo": "约鲁巴语",
"zu": "祖鲁语",
"xh": "科萨语",
"so": "索马里语",
"ha": "豪萨语",
"am": "阿姆哈拉语",
"or": "奥里亚语",
"as": "阿萨姆语",
"mai": "迈蒂利语",
"sat": "桑塔利语",
"sa": "梵语",
"ks": "克什米尔语",
"doi": "多格拉语",
"kok": "孔卡尼语",
"mni": "曼尼普尔语",
"brx": "博多语",
}
func convertHcfyLangToBCP47(hcfyLang string) string {
if bcp47, ok := hcfyLangToBCP47[hcfyLang]; ok {
return bcp47
}
return hcfyLang
}
func convertBCP47ToHcfyLang(bcp47Lang string) string {
if hcfyLang, ok := bcp47ToHcfyLang[bcp47Lang]; ok {
return hcfyLang
}
return bcp47Lang
}
type HcfyTranslateRequest struct {
Name string `json:"name" binding:"required" example:"翻译一"`
Text string `json:"text" binding:"required" example:"Hello, word translation."`
Destination []string `json:"destination" binding:"required" example:"中文(简体),英语"`
Source string `json:"source" example:"英语"`
}
type HcfyPhonetic struct {
Name string `json:"name,omitempty" example:"美"`
TtsURI string `json:"ttsURI,omitempty" example:"https://..."`
Value string `json:"value,omitempty" example:"həˈloʊ"`
}
type HcfyDict struct {
Pos string `json:"pos,omitempty" example:"n."`
Terms []string `json:"terms" example:"你好,问候"`
}
type HcfyTranslateResponse struct {
Text string `json:"text" example:"Hello, word translation."`
From string `json:"from" example:"英语"`
To string `json:"to" example:"中文(简体)"`
TtsURI string `json:"ttsURI,omitempty" example:"https://..."`
Link string `json:"link,omitempty" example:"https://..."`
Phonetic []HcfyPhonetic `json:"phonetic,omitempty"`
Dict []HcfyDict `json:"dict,omitempty"`
Result []string `json:"result,omitempty" example:"你好,划词翻译。"`
}
// HandleHcfyTranslate 划词翻译兼容接口
// @Summary 划词翻译兼容接口
// @Description 兼容划词翻译自定义翻译源接口
// @Tags 插件
// @Accept json
// @Produce json
// @Param token query string false "API Token"
// @Param request body HcfyTranslateRequest true "划词翻译请求"
// @Success 200 {object} HcfyTranslateResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /hcfy [post]
func HandleHcfyTranslate(apiToken string) gin.HandlerFunc {
return func(c *gin.Context) {
if apiToken != "" {
token := c.Query("token")
if token == "" {
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
} else if authHeader != "" {
token = authHeader
}
}
if token != apiToken {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
return
}
}
var req HcfyTranslateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
sourceLang := "auto"
if req.Source != "" {
sourceLang = convertHcfyLangToBCP47(req.Source)
}
if len(req.Destination) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "destination is required",
})
return
}
targetLangName := req.Destination[0]
targetLang := convertHcfyLangToBCP47(targetLangName)
detectedSourceLang := sourceLang
if sourceLang == "auto" {
if containsChinese(req.Text) {
detectedSourceLang = "zh-Hans"
} else if containsJapanese(req.Text) {
detectedSourceLang = "ja"
} else if containsKorean(req.Text) {
detectedSourceLang = "ko"
} else {
detectedSourceLang = "en"
}
}
if detectedSourceLang == targetLang && len(req.Destination) > 1 {
targetLangName = req.Destination[1]
targetLang = convertHcfyLangToBCP47(targetLangName)
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
paragraphs := strings.Split(req.Text, "\n")
results := make([]string, len(paragraphs))
for i, paragraph := range paragraphs {
if paragraph == "" {
results[i] = ""
continue
}
result, err := services.TranslateWithPivot(ctx, detectedSourceLang, targetLang, paragraph, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Translation failed at paragraph %d: %v", i, err),
})
return
}
results[i] = result
}
response := HcfyTranslateResponse{
Text: req.Text,
From: convertBCP47ToHcfyLang(detectedSourceLang),
To: targetLangName,
Result: results,
}
c.JSON(http.StatusOK, response)
}
}
func containsChinese(text string) bool {
for _, r := range text {
if r >= 0x4E00 && r <= 0x9FFF {
return true
}
}
return false
}
func containsJapanese(text string) bool {
for _, r := range text {
if (r >= 0x3040 && r <= 0x309F) ||
(r >= 0x30A0 && r <= 0x30FF) {
return true
}
}
return false
}
func containsKorean(text string) bool {
for _, r := range text {
if r >= 0xAC00 && r <= 0xD7AF {
return true
}
}
return false
}

View File

@@ -0,0 +1,92 @@
package handlers
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/logger"
"github.com/xxnuo/MTranServer/internal/services"
"github.com/xxnuo/MTranServer/internal/utils"
)
type ImmeTranslateRequest struct {
SourceLang string `json:"source_lang" binding:"required" example:"en"`
TargetLang string `json:"target_lang" binding:"required" example:"zh-CN"`
TextList []string `json:"text_list" binding:"required" example:"Hello, world!,Good morning!"`
}
type ImmeTranslation struct {
DetectedSourceLang string `json:"detected_source_lang" example:"en"`
Text string `json:"text" example:"你好,世界!"`
}
type ImmeTranslateResponse struct {
Translations []ImmeTranslation `json:"translations"`
}
// HandleImmeTranslate 沉浸式翻译插件接口
// @Summary 沉浸式翻译插件接口
// @Description 为沉浸式翻译插件提供的翻译接口
// @Tags 插件
// @Accept json
// @Produce json
// @Param token query string false "API Token"
// @Param request body ImmeTranslateRequest true "沉浸式翻译请求"
// @Success 200 {object} ImmeTranslateResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /imme [post]
func HandleImmeTranslate(apiToken string) gin.HandlerFunc {
return func(c *gin.Context) {
if apiToken != "" {
token := c.Query("token")
if token != apiToken {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
return
}
}
var req ImmeTranslateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
sourceLang := utils.NormalizeLanguageCode(req.SourceLang)
targetLang := utils.NormalizeLanguageCode(req.TargetLang)
translations := make([]ImmeTranslation, len(req.TextList))
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
logger.Debug("Imme request: %s -> %s, count: %d", sourceLang, targetLang, len(req.TextList))
for i, text := range req.TextList {
logger.Debug("Imme translating [%d/%d]: %s -> %s, text length: %d, text: %q", i+1, len(req.TextList), sourceLang, targetLang, len(text), text)
result, err := services.TranslateWithPivot(ctx, sourceLang, targetLang, text, true)
if err != nil {
logger.Error("Imme translation failed at index %d (%s -> %s): %v", i, sourceLang, targetLang, err)
result = text // Fallback to original text
} else {
logger.Debug("Imme translated [%d/%d] success", i+1, len(req.TextList))
}
translations[i] = ImmeTranslation{
DetectedSourceLang: req.SourceLang,
Text: result,
}
}
c.JSON(http.StatusOK, ImmeTranslateResponse{
Translations: translations,
})
}
}

View File

@@ -0,0 +1,153 @@
package handlers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/services"
"github.com/xxnuo/MTranServer/internal/utils"
)
type KissTranslateRequest struct {
From string `json:"from" binding:"required" example:"en"`
To string `json:"to" binding:"required" example:"zh-CN"`
Text string `json:"text" binding:"required" example:"Hello, world!"`
}
type KissTranslateResponse struct {
Text string `json:"text" example:"你好,世界!"`
Src string `json:"src" example:"en"`
}
type KissBatchTranslateRequest struct {
From string `json:"from" binding:"required" example:"auto"`
To string `json:"to" binding:"required" example:"zh-CN"`
Texts []string `json:"texts" binding:"required" example:"Hello,World"`
}
type KissBatchTranslateItem struct {
Text string `json:"text" example:"你好"`
Src string `json:"src" example:"en"`
}
type KissBatchTranslateResponse struct {
Translations []KissBatchTranslateItem `json:"translations"`
}
// HandleKissTranslate 简约翻译插件接口(非聚合)
// @Summary 简约翻译插件接口(非聚合)
// @Description 为简约翻译插件提供的单文本翻译接口
// @Tags 插件
// @Accept json
// @Produce json
// @Param KEY header string false "API Token"
// @Param request body KissTranslateRequest true "简约翻译请求"
// @Success 200 {object} KissTranslateResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /kiss [post]
func HandleKissTranslate(apiToken string) gin.HandlerFunc {
return func(c *gin.Context) {
if apiToken != "" {
token := c.GetHeader("KEY")
if token != apiToken {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
return
}
}
var rawReq map[string]interface{}
if err := c.ShouldBindJSON(&rawReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
if texts, ok := rawReq["texts"].([]interface{}); ok && len(texts) > 0 {
var batchReq KissBatchTranslateRequest
batchReq.From, _ = rawReq["from"].(string)
batchReq.To, _ = rawReq["to"].(string)
for _, t := range texts {
if str, ok := t.(string); ok {
batchReq.Texts = append(batchReq.Texts, str)
}
}
if batchReq.From == "" || batchReq.To == "" || len(batchReq.Texts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid batch request",
})
return
}
handleBatchTranslate(c, batchReq)
return
}
var req KissTranslateRequest
req.From, _ = rawReq["from"].(string)
req.To, _ = rawReq["to"].(string)
req.Text, _ = rawReq["text"].(string)
if req.From == "" || req.To == "" || req.Text == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Missing required fields: from, to, text",
})
return
}
fromLang := utils.NormalizeLanguageCode(req.From)
toLang := utils.NormalizeLanguageCode(req.To)
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
result, err := services.TranslateWithPivot(ctx, fromLang, toLang, req.Text, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Translation failed: %v", err),
})
return
}
c.JSON(http.StatusOK, gin.H{
"text": result,
"src": req.From,
})
}
}
func handleBatchTranslate(c *gin.Context, req KissBatchTranslateRequest) {
fromLang := utils.NormalizeLanguageCode(req.From)
toLang := utils.NormalizeLanguageCode(req.To)
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
translations := make([]KissBatchTranslateItem, 0, len(req.Texts))
for _, text := range req.Texts {
result, err := services.TranslateWithPivot(ctx, fromLang, toLang, text, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Translation failed: %v", err),
})
return
}
translations = append(translations, KissBatchTranslateItem{
Text: result,
Src: req.From,
})
}
c.JSON(http.StatusOK, KissBatchTranslateResponse{
Translations: translations,
})
}

View File

@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/models"
)
// handleLanguages 获取支持的语言列表
// @Summary 获取支持的语言列表
// @Description 返回所有支持的翻译语言代码
// @Tags 翻译
// @Produce json
// @Success 200 {object} map[string][]string
// @Failure 500 {object} map[string]string
// @Security ApiKeyAuth
// @Security ApiKeyQuery
// @Router /languages [get]
func HandleLanguages(c *gin.Context) {
if models.GlobalRecords == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Records not initialized",
})
return
}
langMap := make(map[string]bool)
for _, record := range models.GlobalRecords.Data {
langMap[record.SourceLanguage] = true
langMap[record.TargetLanguage] = true
}
languages := make([]string, 0, len(langMap))
for lang := range langMap {
languages = append(languages, lang)
}
c.JSON(http.StatusOK, gin.H{
"languages": languages,
})
}

View File

@@ -0,0 +1,53 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/xxnuo/MTranServer/internal/models"
)
func TestHandleLanguages(t *testing.T) {
gin.SetMode(gin.TestMode)
models.GlobalRecords = &models.RecordsData{
Data: []models.RecordItem{
{SourceLanguage: "en", TargetLanguage: "zh-Hans"},
{SourceLanguage: "zh-Hans", TargetLanguage: "en"},
{SourceLanguage: "en", TargetLanguage: "ja"},
},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
HandleLanguages(c)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "languages")
assert.Contains(t, w.Body.String(), "en")
assert.Contains(t, w.Body.String(), "zh-Hans")
assert.Contains(t, w.Body.String(), "ja")
}
func TestHandleLanguagesNotInitialized(t *testing.T) {
gin.SetMode(gin.TestMode)
originalRecords := models.GlobalRecords
models.GlobalRecords = nil
defer func() {
models.GlobalRecords = originalRecords
}()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
HandleLanguages(c)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "error")
assert.Contains(t, w.Body.String(), "Records not initialized")
}

View File

@@ -0,0 +1,57 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/version"
)
// handleVersion 获取服务版本
// @Summary 获取服务版本
// @Description 返回当前服务的版本号
// @Tags 系统
// @Produce json
// @Success 200 {object} map[string]string
// @Router /version [get]
func HandleVersion(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"server": version.GetVersion(),
"worker": version.GetWorkerVersion(),
})
}
// handleHealth 健康检查
// @Summary 健康检查
// @Description 检查服务是否正常运行
// @Tags 系统
// @Produce json
// @Success 200 {object} map[string]string
// @Router /health [get]
func HandleHealth(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
// handleHeartbeat 心跳检查
// @Summary 心跳检查
// @Description 返回服务状态
// @Tags 系统
// @Produce plain
// @Success 200 {string} string "Ready"
// @Router /__heartbeat__ [get]
func HandleHeartbeat(c *gin.Context) {
c.String(http.StatusOK, "Ready")
}
// handleLBHeartbeat 负载均衡心跳检查
// @Summary 负载均衡心跳检查
// @Description 返回负载均衡器心跳状态
// @Tags 系统
// @Produce plain
// @Success 200 {string} string "Ready"
// @Router /__lbheartbeat__ [get]
func HandleLBHeartbeat(c *gin.Context) {
c.String(http.StatusOK, "Ready")
}

View File

@@ -0,0 +1,61 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/xxnuo/MTranServer/internal/version"
)
func TestHandleVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
HandleVersion(c)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "version")
assert.Contains(t, w.Body.String(), version.GetVersion())
}
func TestHandleHealth(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
HandleHealth(c)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "status")
assert.Contains(t, w.Body.String(), "ok")
}
func TestHandleHeartbeat(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
HandleHeartbeat(c)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "Ready", w.Body.String())
}
func TestHandleLBHeartbeat(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
HandleLBHeartbeat(c)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "Ready", w.Body.String())
}

View File

@@ -0,0 +1,131 @@
package handlers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/logger"
"github.com/xxnuo/MTranServer/internal/services"
"github.com/xxnuo/MTranServer/internal/utils"
)
// TranslateRequest 翻译请求
type TranslateRequest struct {
From string `json:"from" binding:"required" example:"en"`
To string `json:"to" binding:"required" example:"zh-Hans"`
Text string `json:"text" binding:"required" example:"Hello, world!"`
HTML bool `json:"html" example:"false"`
}
// TranslateResponse 翻译响应
type TranslateResponse struct {
Result string `json:"result" example:"你好,世界!"`
}
// handleTranslate 单文本翻译
// @Summary 单文本翻译
// @Description 翻译单个文本
// @Tags 翻译
// @Accept json
// @Produce json
// @Param request body TranslateRequest true "翻译请求"
// @Success 200 {object} TranslateResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security ApiKeyAuth
// @Security ApiKeyQuery
// @Router /translate [post]
func HandleTranslate(c *gin.Context) {
var req TranslateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
req.From = utils.NormalizeLanguageCode(req.From)
req.To = utils.NormalizeLanguageCode(req.To)
logger.Debug("Translation request: %s -> %s, text length: %d", req.From, req.To, len(req.Text))
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
result, err := services.TranslateWithPivot(ctx, req.From, req.To, req.Text, req.HTML)
if err != nil {
logger.Error("Translation failed (%s -> %s): %v", req.From, req.To, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Translation failed: %v", err),
})
return
}
logger.Debug("Translation completed: %s -> %s", req.From, req.To)
c.JSON(http.StatusOK, gin.H{
"result": result,
})
}
type TranslateBatchRequest struct {
From string `json:"from" binding:"required" example:"en"`
To string `json:"to" binding:"required" example:"zh-Hans"`
Texts []string `json:"texts" binding:"required" example:"Hello, world!,Good morning!"`
HTML bool `json:"html" example:"false"`
}
type TranslateBatchResponse struct {
Results []string `json:"results" example:"你好,世界!,早上好!"`
}
// handleTranslateBatch 批量翻译
// @Summary 批量翻译
// @Description 批量翻译多个文本
// @Tags 翻译
// @Accept json
// @Produce json
// @Param request body TranslateBatchRequest true "批量翻译请求"
// @Success 200 {object} TranslateBatchResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security ApiKeyAuth
// @Security ApiKeyQuery
// @Router /translate/batch [post]
func HandleTranslateBatch(c *gin.Context) {
var req TranslateBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
req.From = utils.NormalizeLanguageCode(req.From)
req.To = utils.NormalizeLanguageCode(req.To)
logger.Debug("Batch translation request: %s -> %s, count: %d", req.From, req.To, len(req.Texts))
results := make([]string, len(req.Texts))
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
for i, text := range req.Texts {
result, err := services.TranslateWithPivot(ctx, req.From, req.To, text, req.HTML)
if err != nil {
logger.Error("Batch translation failed at index %d (%s -> %s): %v", i, req.From, req.To, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Translation failed at index %d: %v", i, err),
})
return
}
results[i] = result
}
logger.Debug("Batch translation completed: %s -> %s, count: %d", req.From, req.To, len(req.Texts))
c.JSON(http.StatusOK, gin.H{
"results": results,
})
}

View File

@@ -0,0 +1,119 @@
package logger
import (
"fmt"
"log"
"os"
"strings"
)
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARN
ERROR
)
const (
colorReset = "\033[0m"
colorCyan = "\033[36m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorRed = "\033[31m"
)
var (
currentLevel LogLevel = INFO
debugLogger *log.Logger
infoLogger *log.Logger
warnLogger *log.Logger
errorLogger *log.Logger
)
func init() {
debugLogger = log.New(os.Stdout, colorCyan+"[DEBUG]"+colorReset+" ", log.Ldate|log.Ltime|log.Lshortfile)
infoLogger = log.New(os.Stdout, colorGreen+"[INFO]"+colorReset+" ", log.Ldate|log.Ltime)
warnLogger = log.New(os.Stdout, colorYellow+"[WARN]"+colorReset+" ", log.Ldate|log.Ltime)
errorLogger = log.New(os.Stderr, colorRed+"[ERROR]"+colorReset+" ", log.Ldate|log.Ltime|log.Lshortfile)
}
func SetLevel(level string) {
switch strings.ToLower(level) {
case "debug":
currentLevel = DEBUG
case "info":
currentLevel = INFO
case "warn", "warning":
currentLevel = WARN
case "error":
currentLevel = ERROR
default:
currentLevel = INFO
}
}
func GetLevel() string {
switch currentLevel {
case DEBUG:
return "debug"
case INFO:
return "info"
case WARN:
return "warn"
case ERROR:
return "error"
default:
return "info"
}
}
func Debug(format string, v ...interface{}) {
if currentLevel <= DEBUG {
debugLogger.Output(2, fmt.Sprintf(format, v...))
}
}
func Info(format string, v ...interface{}) {
if currentLevel <= INFO {
infoLogger.Output(2, fmt.Sprintf(format, v...))
}
}
func Warn(format string, v ...interface{}) {
if currentLevel <= WARN {
warnLogger.Output(2, fmt.Sprintf(format, v...))
}
}
func Error(format string, v ...interface{}) {
if currentLevel <= ERROR {
errorLogger.Output(2, fmt.Sprintf(format, v...))
}
}
func Fatal(format string, v ...interface{}) {
errorLogger.Output(2, fmt.Sprintf(format, v...))
os.Exit(1)
}
func Debugf(format string, v ...interface{}) {
Debug(format, v...)
}
func Infof(format string, v ...interface{}) {
Info(format, v...)
}
func Warnf(format string, v ...interface{}) {
Warn(format, v...)
}
func Errorf(format string, v ...interface{}) {
Error(format, v...)
}
func Fatalf(format string, v ...interface{}) {
Fatal(format, v...)
}

View File

@@ -0,0 +1,247 @@
package manager
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/xxnuo/MTranServer/internal/logger"
)
type WSMessage struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
type WSResponse struct {
Type string `json:"type"`
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data,omitempty"`
}
type TransRequest struct {
Text string `json:"text"`
HTML bool `json:"html"`
}
type ExitRequest struct {
Time int `json:"time"`
Force bool `json:"force"`
}
type HealthResponse struct {
Ready bool `json:"ready"`
}
type TransResponse struct {
TranslatedText string `json:"translated_text"`
}
type ExitResponse struct {
Message string `json:"message"`
}
type Client struct {
url string
conn *websocket.Conn
mu sync.RWMutex
timeout time.Duration
connected bool
reconnect bool
closeChan chan struct{}
closeOnce sync.Once
}
type ClientOption func(*Client)
func WithTimeout(timeout time.Duration) ClientOption {
return func(c *Client) {
c.timeout = timeout
}
}
func WithReconnect(reconnect bool) ClientOption {
return func(c *Client) {
c.reconnect = reconnect
}
}
func NewClient(url string, opts ...ClientOption) *Client {
c := &Client{
url: url,
timeout: 30 * time.Second,
reconnect: false,
closeChan: make(chan struct{}),
}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Client) Connect() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return nil
}
dialer := websocket.Dialer{
HandshakeTimeout: c.timeout,
}
conn, _, err := dialer.Dial(c.url, nil)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
c.conn = conn
c.connected = true
return nil
}
func (c *Client) Close() error {
var err error
c.closeOnce.Do(func() {
close(c.closeChan)
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
err = c.conn.Close()
c.connected = false
}
})
return err
}
func (c *Client) IsConnected() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.connected
}
func (c *Client) sendRequest(ctx context.Context, msgType string, data interface{}) (*WSResponse, error) {
dataBytes, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("failed to marshal data: %w", err)
}
msg := WSMessage{
Type: msgType,
Data: dataBytes,
}
reqCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return nil, fmt.Errorf("not connected")
}
if err := c.conn.WriteJSON(msg); err != nil {
c.connected = false
return nil, fmt.Errorf("failed to send message: %w", err)
}
responseChan := make(chan *WSResponse, 1)
errChan := make(chan error, 1)
go func() {
var resp WSResponse
if err := c.conn.ReadJSON(&resp); err != nil {
errChan <- fmt.Errorf("failed to read response: %w", err)
return
}
responseChan <- &resp
}()
select {
case <-reqCtx.Done():
c.connected = false
return nil, fmt.Errorf("request timeout")
case err := <-errChan:
c.connected = false
return nil, err
case resp := <-responseChan:
return resp, nil
}
}
func (c *Client) Health(ctx context.Context) (bool, error) {
resp, err := c.sendRequest(ctx, "health", struct{}{})
if err != nil {
return false, err
}
if resp.Code != 200 {
return false, fmt.Errorf("health check failed (code %d): %s", resp.Code, resp.Msg)
}
var result HealthResponse
if resp.Data != nil {
if err := json.Unmarshal(resp.Data, &result); err != nil {
return false, fmt.Errorf("failed to unmarshal response: %w", err)
}
}
return result.Ready, nil
}
func (c *Client) Trans(ctx context.Context, req TransRequest) (string, error) {
logger.Debug("Client.Trans: sending request, text length: %d, isHTML: %v, text: %q", len(req.Text), req.HTML, req.Text)
resp, err := c.sendRequest(ctx, "trans", req)
if err != nil {
logger.Debug("Client.Trans: sendRequest error: %v", err)
return "", err
}
if resp.Code != 200 {
logger.Debug("Client.Trans: response code %d: %s", resp.Code, resp.Msg)
return "", fmt.Errorf("trans failed (code %d): %s", resp.Code, resp.Msg)
}
var result TransResponse
if resp.Data != nil {
if err := json.Unmarshal(resp.Data, &result); err != nil {
logger.Debug("Client.Trans: unmarshal error: %v", err)
return "", fmt.Errorf("failed to unmarshal response: %w", err)
}
}
logger.Debug("Client.Trans: success, result length: %d", len(result.TranslatedText))
return result.TranslatedText, nil
}
func (c *Client) Exit(ctx context.Context, req ExitRequest) (*ExitResponse, error) {
resp, err := c.sendRequest(ctx, "exit", req)
if err != nil {
return nil, err
}
if resp.Code != 200 {
return nil, fmt.Errorf("exit failed (code %d): %s", resp.Code, resp.Msg)
}
var result ExitResponse
if resp.Data != nil {
if err := json.Unmarshal(resp.Data, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
} else {
result.Message = resp.Msg
}
return &result, nil
}

View File

@@ -0,0 +1,359 @@
package manager_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xxnuo/MTranServer/internal/manager"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func mockWSServer(t *testing.T, handler func(*websocket.Conn)) *httptest.Server {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
require.NoError(t, err)
defer conn.Close()
handler(conn)
}))
return server
}
func handleEcho(conn *websocket.Conn) {
for {
var msg manager.WSMessage
if err := conn.ReadJSON(&msg); err != nil {
break
}
resp := manager.WSResponse{
Type: msg.Type,
Code: 200,
Msg: "success",
Data: msg.Data,
}
if err := conn.WriteJSON(resp); err != nil {
break
}
}
}
func handleHealth(conn *websocket.Conn, ready bool) {
var msg manager.WSMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
resp := manager.WSResponse{
Type: "health",
Code: 200,
Msg: "success",
}
data := map[string]bool{"ready": ready}
resp.Data, _ = json.Marshal(data)
conn.WriteJSON(resp)
}
func handleTrans(conn *websocket.Conn) {
var msg manager.WSMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
var req manager.TransRequest
json.Unmarshal(msg.Data, &req)
resp := manager.WSResponse{
Type: "trans",
Code: 200,
Msg: "success",
}
if req.Text == "" {
resp.Code = 400
resp.Msg = "text is required"
} else {
data := map[string]string{"translated_text": "translated: " + req.Text}
resp.Data, _ = json.Marshal(data)
}
conn.WriteJSON(resp)
}
func handleCompute(conn *websocket.Conn) {
var msg manager.WSMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
var req struct {
Text string `json:"text"`
HTML bool `json:"html"`
}
json.Unmarshal(msg.Data, &req)
resp := manager.WSResponse{
Type: "trans",
Code: 200,
Msg: "success",
}
if req.Text == "" {
resp.Code = 400
resp.Msg = "text is required"
} else {
data := map[string]string{"translated_text": "翻译结果: " + req.Text}
resp.Data, _ = json.Marshal(data)
}
conn.WriteJSON(resp)
}
func TestClient_Connect(t *testing.T) {
server := mockWSServer(t, handleEcho)
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL)
defer client.Close()
err := client.Connect()
assert.NoError(t, err)
assert.True(t, client.IsConnected())
}
func TestClient_ConnectTwice(t *testing.T) {
server := mockWSServer(t, handleEcho)
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL)
defer client.Close()
err := client.Connect()
require.NoError(t, err)
err = client.Connect()
assert.NoError(t, err)
assert.True(t, client.IsConnected())
}
func TestClient_Health(t *testing.T) {
server := mockWSServer(t, func(conn *websocket.Conn) {
handleHealth(conn, true)
})
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL)
defer client.Close()
err := client.Connect()
require.NoError(t, err)
ctx := context.Background()
ready, err := client.Health(ctx)
assert.NoError(t, err)
assert.True(t, ready)
}
func TestClient_Health_NotReady(t *testing.T) {
server := mockWSServer(t, func(conn *websocket.Conn) {
handleHealth(conn, false)
})
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL)
defer client.Close()
err := client.Connect()
require.NoError(t, err)
ctx := context.Background()
ready, err := client.Health(ctx)
assert.NoError(t, err)
assert.False(t, ready)
}
func TestClient_Trans(t *testing.T) {
server := mockWSServer(t, handleTrans)
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL)
defer client.Close()
err := client.Connect()
require.NoError(t, err)
ctx := context.Background()
result, err := client.Trans(ctx, manager.TransRequest{
Text: "Hello",
HTML: false,
})
assert.NoError(t, err)
assert.Equal(t, "translated: Hello", result)
}
func TestClient_Trans_EmptyText(t *testing.T) {
server := mockWSServer(t, handleTrans)
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL)
defer client.Close()
err := client.Connect()
require.NoError(t, err)
ctx := context.Background()
_, err = client.Trans(ctx, manager.TransRequest{
Text: "",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "text is required")
}
func TestClient_Timeout(t *testing.T) {
server := mockWSServer(t, func(conn *websocket.Conn) {
time.Sleep(5 * time.Second)
})
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL, manager.WithTimeout(100*time.Millisecond))
defer client.Close()
err := client.Connect()
require.NoError(t, err)
ctx := context.Background()
_, err = client.Health(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "timeout")
}
func TestClient_NotConnected(t *testing.T) {
client := manager.NewClient("ws://localhost:9999")
defer client.Close()
ctx := context.Background()
_, err := client.Health(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not connected")
}
func TestClient_Exit(t *testing.T) {
server := mockWSServer(t, func(conn *websocket.Conn) {
var msg manager.WSMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
data := map[string]string{"message": "Shutdown initiated"}
dataBytes, _ := json.Marshal(data)
resp := manager.WSResponse{
Type: "exit",
Code: 200,
Msg: "success",
Data: dataBytes,
}
conn.WriteJSON(resp)
})
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL)
defer client.Close()
err := client.Connect()
require.NoError(t, err)
ctx := context.Background()
resp, err := client.Exit(ctx, manager.ExitRequest{
Time: 0,
Force: true,
})
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Shutdown initiated", resp.Message)
}
func TestClient_MultipleRequests(t *testing.T) {
server := mockWSServer(t, func(conn *websocket.Conn) {
for i := 0; i < 3; i++ {
var msg manager.WSMessage
if err := conn.ReadJSON(&msg); err != nil {
break
}
var req manager.TransRequest
json.Unmarshal(msg.Data, &req)
data := map[string]string{"translated_text": "translated: " + req.Text}
dataBytes, _ := json.Marshal(data)
resp := manager.WSResponse{
Type: "trans",
Code: 200,
Msg: "success",
Data: dataBytes,
}
if err := conn.WriteJSON(resp); err != nil {
break
}
}
})
defer server.Close()
wsURL := "ws" + server.URL[4:]
client := manager.NewClient(wsURL)
defer client.Close()
err := client.Connect()
require.NoError(t, err)
ctx := context.Background()
for i := 1; i <= 3; i++ {
result, err := client.Trans(ctx, manager.TransRequest{
Text: "Test " + string(rune('0'+i)),
})
assert.NoError(t, err)
assert.Contains(t, result, "translated:")
}
}

View File

@@ -0,0 +1,422 @@
package manager
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"sync"
"syscall"
"time"
"github.com/xxnuo/MTranServer/bin"
"github.com/xxnuo/MTranServer/internal/config"
"github.com/xxnuo/MTranServer/internal/logger"
)
const (
maxLogLines = 1000
)
var (
workerBinaryInitialized bool
workerBinaryMu sync.Mutex
)
type WorkerArgs struct {
Host string
Port int
WorkDir string
ModelDir string
ModelPath string
LexicalPath string
VocabPaths []string
EnableGRPC bool
EnableHTTP bool
EnableWebSocket bool
GRPCUnixSocket string
LogLevel string
BinaryPath string
}
func NewWorkerArgs() *WorkerArgs {
return &WorkerArgs{
Host: "127.0.0.1",
Port: 8988,
WorkDir: ".",
EnableGRPC: false,
EnableHTTP: false,
EnableWebSocket: true,
GRPCUnixSocket: "",
LogLevel: "warning",
}
}
type Worker struct {
args *WorkerArgs
cmd *exec.Cmd
id string
binaryPath string
mu sync.RWMutex
logMu sync.RWMutex
logs []string
maxLogs int
done chan struct{}
wg sync.WaitGroup
running bool
pid int
}
func NewWorker(args *WorkerArgs) *Worker {
binaryPath := args.BinaryPath
if binaryPath == "" {
cfg := config.GetConfig()
binaryName := "mtrancore"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
binaryPath = filepath.Join(cfg.ConfigDir, "bin", binaryName)
}
workerID := fmt.Sprintf("mtran-worker-%d", args.Port)
w := &Worker{
args: args,
id: workerID,
binaryPath: binaryPath,
logs: make([]string, 0, maxLogLines),
maxLogs: maxLogLines,
done: make(chan struct{}),
running: false,
pid: 0,
}
return w
}
func EnsureWorkerBinary(cfg *config.Config) error {
workerBinaryMu.Lock()
defer workerBinaryMu.Unlock()
if workerBinaryInitialized {
return nil
}
binaryName := "mtrancore"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
binaryPath := filepath.Join(cfg.ConfigDir, "bin", binaryName)
if data, err := os.ReadFile(binaryPath); err == nil {
existingHash := fmt.Sprintf("%x", bin.ComputeHash(data))
if existingHash == bin.WorkerHash {
logger.Debug("Worker binary already exists and is up to date")
workerBinaryInitialized = true
return nil
}
logger.Info("Worker binary hash mismatch, updating...")
}
if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for worker binary: %w", err)
}
logger.Info("Extracting worker binary to %s", binaryPath)
if err := os.WriteFile(binaryPath, bin.WorkerBinary, 0755); err != nil {
return fmt.Errorf("failed to write worker binary: %w", err)
}
logger.Info("Worker binary extracted successfully")
workerBinaryInitialized = true
return nil
}
func (w *Worker) buildArgs() []string {
args := []string{
"--host", w.args.Host,
"--port", strconv.Itoa(w.args.Port),
"--log-level", w.args.LogLevel,
}
if w.args.ModelDir != "" {
args = append(args, "--model-dir", w.args.ModelDir)
} else if w.args.ModelPath != "" {
args = append(args, "--model-path", w.args.ModelPath)
if w.args.LexicalPath != "" {
args = append(args, "--lexical-shortlist-path", w.args.LexicalPath)
}
for _, vocabPath := range w.args.VocabPaths {
args = append(args, "--vocabulary-path", vocabPath)
}
}
if w.args.EnableGRPC {
args = append(args, "--enable-grpc", "true")
} else {
args = append(args, "--enable-grpc", "false")
}
if w.args.EnableHTTP {
args = append(args, "--enable-http", "true")
} else {
args = append(args, "--enable-http", "false")
}
if w.args.EnableWebSocket {
args = append(args, "--enable-websocket", "true")
} else {
args = append(args, "--enable-websocket", "false")
}
if w.args.GRPCUnixSocket != "" {
args = append(args, "--grpc-unix-socket", w.args.GRPCUnixSocket)
}
return args
}
func (w *Worker) Start() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.running && w.cmd != nil && w.cmd.Process != nil {
return fmt.Errorf("worker already running")
}
if _, err := os.Stat(w.binaryPath); err != nil {
return fmt.Errorf("worker binary not found at %s: %w", w.binaryPath, err)
}
args := w.buildArgs()
logger.Debug("Starting worker %s on port %d", w.id, w.args.Port)
cmd := exec.Command(w.binaryPath, args...)
cmd.Dir = w.args.WorkDir
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start worker: %w", err)
}
w.cmd = cmd
w.running = true
w.pid = cmd.Process.Pid
w.wg.Add(3)
go w.collectLogs(stdoutPipe, "INFO")
go w.collectLogs(stderrPipe, "ERROR")
go w.monitorProcess()
logger.Debug("Worker %s started with PID %d", w.id, w.pid)
return nil
}
func (w *Worker) monitorProcess() {
defer w.wg.Done()
err := w.cmd.Wait()
w.mu.Lock()
defer w.mu.Unlock()
if w.running {
w.running = false
w.pid = 0
if err != nil {
logger.Warn("Worker %s process exited unexpectedly: %v", w.id, err)
} else {
logger.Info("Worker %s process exited normally", w.id)
}
}
}
func (w *Worker) Stop() error {
w.mu.Lock()
if !w.running || w.cmd == nil || w.cmd.Process == nil {
w.mu.Unlock()
return fmt.Errorf("worker not running")
}
logger.Debug("Stopping worker %s", w.id)
if err := w.cmd.Process.Signal(syscall.SIGTERM); err != nil {
logger.Warn("Failed to send SIGTERM to worker: %v", err)
}
w.mu.Unlock()
timeout := time.After(10 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeout:
w.mu.Lock()
if w.running && w.cmd != nil && w.cmd.Process != nil {
logger.Warn("Worker %s stop timeout, forcing kill", w.id)
if err := w.cmd.Process.Kill(); err != nil {
logger.Warn("Failed to kill worker: %v", err)
}
}
w.mu.Unlock()
time.Sleep(500 * time.Millisecond)
return fmt.Errorf("worker stop timeout, forced kill")
case <-ticker.C:
w.mu.RLock()
stillRunning := w.running
w.mu.RUnlock()
if !stillRunning {
logger.Debug("Worker %s stopped", w.id)
return nil
}
}
}
}
func (w *Worker) Restart() error {
if err := w.Stop(); err != nil {
logger.Warn("Failed to stop worker during restart: %v", err)
}
time.Sleep(500 * time.Millisecond)
return w.Start()
}
func (w *Worker) Status() string {
w.mu.RLock()
defer w.mu.RUnlock()
if !w.running || w.cmd == nil || w.cmd.Process == nil {
return "not_started"
}
return "running"
}
func (w *Worker) Logs() []string {
w.logMu.RLock()
defer w.logMu.RUnlock()
logsCopy := make([]string, len(w.logs))
copy(logsCopy, w.logs)
return logsCopy
}
func (w *Worker) collectLogs(pipe io.ReadCloser, logType string) {
defer w.wg.Done()
defer pipe.Close()
scanner := bufio.NewScanner(pipe)
for scanner.Scan() {
select {
case <-w.done:
return
default:
w.logMu.Lock()
logLine := fmt.Sprintf("[%s] [%s] %s",
time.Now().Format("2006-01-02 15:04:05"), logType, scanner.Text())
w.logs = append(w.logs, logLine)
if len(w.logs) > w.maxLogs {
w.logs = w.logs[len(w.logs)-w.maxLogs:]
}
w.logMu.Unlock()
}
}
}
func (w *Worker) IsRunning() bool {
return w.Status() == "running"
}
func (w *Worker) Signal(sig syscall.Signal) error {
w.mu.Lock()
defer w.mu.Unlock()
if !w.running || w.cmd == nil || w.cmd.Process == nil {
return fmt.Errorf("worker not found")
}
return w.cmd.Process.Signal(sig)
}
func (w *Worker) Cleanup() error {
var errs []error
w.mu.Lock()
if w.running && w.cmd != nil && w.cmd.Process != nil {
logger.Debug("Stopping worker %s during cleanup", w.id)
if err := w.cmd.Process.Signal(syscall.SIGTERM); err != nil {
logger.Warn("Failed to send SIGTERM during cleanup: %v", err)
}
w.mu.Unlock()
timeout := time.After(5 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
waitLoop:
for {
select {
case <-timeout:
w.mu.Lock()
if w.running && w.cmd != nil && w.cmd.Process != nil {
logger.Warn("Worker %s cleanup timeout, forcing kill", w.id)
if err := w.cmd.Process.Kill(); err != nil {
logger.Warn("Failed to kill worker during cleanup: %v", err)
errs = append(errs, fmt.Errorf("failed to kill worker: %w", err))
}
}
w.mu.Unlock()
time.Sleep(500 * time.Millisecond)
break waitLoop
case <-ticker.C:
w.mu.RLock()
stillRunning := w.running
w.mu.RUnlock()
if !stillRunning {
break waitLoop
}
}
}
w.mu.Lock()
} else {
logger.Debug("Worker %s not running during cleanup", w.id)
}
select {
case <-w.done:
default:
close(w.done)
}
w.mu.Unlock()
w.wg.Wait()
if len(errs) > 0 {
return fmt.Errorf("cleanup errors: %v", errs)
}
return nil
}

View File

@@ -0,0 +1,180 @@
package manager_test
import (
"testing"
"time"
"github.com/xxnuo/MTranServer/internal/manager"
"github.com/xxnuo/MTranServer/internal/utils"
)
func TestWorkerPanicRecovery(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args := manager.NewWorkerArgs()
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
worker := manager.NewWorker(args)
defer worker.Cleanup()
if err := worker.Start(); err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
time.Sleep(1 * time.Second)
if !worker.IsRunning() {
t.Fatal("Worker should be running")
}
if err := worker.Stop(); err != nil {
t.Logf("Stop returned error (may be expected): %v", err)
}
time.Sleep(500 * time.Millisecond)
if err := worker.Start(); err != nil {
t.Fatalf("Failed to restart worker after stop: %v", err)
}
time.Sleep(1 * time.Second)
if !worker.IsRunning() {
t.Fatal("Worker should be running after restart")
}
t.Log("Worker successfully recovered from panic scenario")
}
func TestMultipleStopStartCycles(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args := manager.NewWorkerArgs()
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
worker := manager.NewWorker(args)
defer worker.Cleanup()
cycles := 5
for i := 0; i < cycles; i++ {
t.Logf("Cycle %d: Starting worker", i+1)
if err := worker.Start(); err != nil {
t.Fatalf("Cycle %d: Failed to start worker: %v", i+1, err)
}
time.Sleep(500 * time.Millisecond)
if !worker.IsRunning() {
t.Fatalf("Cycle %d: Worker should be running", i+1)
}
t.Logf("Cycle %d: Stopping worker", i+1)
if err := worker.Stop(); err != nil {
t.Logf("Cycle %d: Stop returned error: %v", i+1, err)
}
time.Sleep(500 * time.Millisecond)
status := worker.Status()
if status == "running" {
t.Fatalf("Cycle %d: Worker should not be running after stop, status: %s", i+1, status)
}
}
t.Logf("Successfully completed %d stop/start cycles", cycles)
}
func TestRestartUnderLoad(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args := manager.NewWorkerArgs()
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
worker := manager.NewWorker(args)
defer worker.Cleanup()
if err := worker.Start(); err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
time.Sleep(1 * time.Second)
restarts := 3
for i := 0; i < restarts; i++ {
t.Logf("Restart attempt %d", i+1)
if err := worker.Restart(); err != nil {
t.Fatalf("Restart %d failed: %v", i+1, err)
}
time.Sleep(1 * time.Second)
if !worker.IsRunning() {
t.Fatalf("Worker should be running after restart %d", i+1)
}
}
t.Logf("Successfully completed %d restarts", restarts)
}
func TestCleanupAfterPanic(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args := manager.NewWorkerArgs()
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
worker := manager.NewWorker(args)
if err := worker.Start(); err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
time.Sleep(1 * time.Second)
if err := worker.Cleanup(); err != nil {
t.Logf("Cleanup returned error (may be expected on Windows): %v", err)
}
time.Sleep(500 * time.Millisecond)
worker2 := manager.NewWorker(args)
defer worker2.Cleanup()
if err := worker2.Start(); err != nil {
t.Fatalf("Failed to start new worker with same ID after cleanup: %v", err)
}
time.Sleep(1 * time.Second)
if !worker2.IsRunning() {
t.Fatal("Second worker should be running")
}
t.Log("Successfully cleaned up and restarted worker with same ID")
}

View File

@@ -0,0 +1,394 @@
package manager_test
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/xxnuo/MTranServer/internal/manager"
"github.com/xxnuo/MTranServer/internal/utils"
)
func TestWorker_ConcurrentStartStop(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
defer worker.Cleanup()
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
worker.Start()
time.Sleep(50 * time.Millisecond)
}()
}
wg.Wait()
time.Sleep(500 * time.Millisecond)
worker.Stop()
}
func TestWorker_ConcurrentStatusCheck(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
defer worker.Cleanup()
err = worker.Start()
if err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
var wg sync.WaitGroup
for range 20 {
wg.Add(1)
go func() {
defer wg.Done()
for range 10 {
_ = worker.Status()
_ = worker.IsRunning()
_ = worker.Status()
_ = worker.Logs()
time.Sleep(10 * time.Millisecond)
}
}()
}
wg.Wait()
worker.Stop()
}
func TestWorker_ConcurrentRestart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
defer worker.Cleanup()
err = worker.Start()
if err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
time.Sleep(500 * time.Millisecond)
for range 3 {
err = worker.Restart()
if err != nil {
t.Logf("Restart error (may be expected): %v", err)
}
time.Sleep(500 * time.Millisecond)
}
if worker.IsRunning() {
worker.Stop()
}
}
func TestWorker_StartStopCycle(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
defer worker.Cleanup()
for range 5 {
err := worker.Start()
if err != nil {
t.Logf("Start error (expected on concurrent calls): %v", err)
}
time.Sleep(300 * time.Millisecond)
if worker.IsRunning() {
err = worker.Stop()
if err != nil {
t.Logf("Stop error: %v", err)
}
}
time.Sleep(200 * time.Millisecond)
}
}
func TestWorker_CleanupWhileRunning(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
err = worker.Start()
if err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
time.Sleep(300 * time.Millisecond)
err = worker.Cleanup()
assert.NoError(t, err)
}
func TestWorker_MultipleCleanup(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
err = worker.Start()
if err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
time.Sleep(300 * time.Millisecond)
var wg sync.WaitGroup
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
worker.Cleanup()
}()
}
wg.Wait()
}
func TestWorker_LogCollectionDuringCleanup(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "debug"
worker := manager.NewWorker(args)
err = worker.Start()
if err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
var wg sync.WaitGroup
done := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
_ = worker.Logs()
time.Sleep(10 * time.Millisecond)
}
}
}()
time.Sleep(500 * time.Millisecond)
err = worker.Cleanup()
assert.NoError(t, err)
close(done)
wg.Wait()
}
func TestManager_ConcurrentCompute(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
ctx := context.Background()
_, _ = mgr.Translate(ctx, "test")
}()
}
wg.Wait()
mgr.Stop()
}
func TestManager_ConcurrentStartStop(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
mgr := manager.NewManager(args)
defer mgr.Cleanup()
var wg sync.WaitGroup
for range 5 {
wg.Add(1)
go func() {
defer wg.Done()
mgr.Start()
time.Sleep(100 * time.Millisecond)
}()
}
wg.Wait()
time.Sleep(500 * time.Millisecond)
mgr.Stop()
}
func TestManager_StatusChecksDuringRestart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
var wg sync.WaitGroup
done := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
_ = mgr.IsRunning()
_ = mgr.Status()
_ = mgr.Logs()
time.Sleep(10 * time.Millisecond)
}
}
}()
time.Sleep(200 * time.Millisecond)
mgr.Restart()
time.Sleep(200 * time.Millisecond)
close(done)
wg.Wait()
mgr.Stop()
}

View File

@@ -0,0 +1,381 @@
package manager_test
import (
"sync"
"testing"
"time"
"github.com/xxnuo/MTranServer/internal/manager"
"github.com/xxnuo/MTranServer/internal/utils"
)
func TestWorker_StressStartStop(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
defer worker.Cleanup()
iterations := 20
for i := range iterations {
t.Logf("Iteration %d/%d", i+1, iterations)
err := worker.Start()
if err != nil {
t.Logf("Start error: %v", err)
}
time.Sleep(200 * time.Millisecond)
if worker.IsRunning() {
err = worker.Stop()
if err != nil {
t.Errorf("Stop error at iteration %d: %v", i+1, err)
}
}
time.Sleep(100 * time.Millisecond)
}
}
func TestWorker_StressRestart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
defer worker.Cleanup()
err = worker.Start()
if err != nil {
t.Fatalf("Failed initial start: %v", err)
}
time.Sleep(500 * time.Millisecond)
iterations := 15
for i := range iterations {
t.Logf("Restart %d/%d", i+1, iterations)
err := worker.Restart()
if err != nil {
t.Errorf("Restart error at iteration %d: %v", i+1, err)
}
time.Sleep(300 * time.Millisecond)
}
if worker.IsRunning() {
worker.Stop()
}
}
func TestWorker_StressMultipleWorkers(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
workerCount := 5
workers := make([]*manager.Worker, 0, workerCount)
for i := range workerCount {
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args := manager.NewWorkerArgs()
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
workers = append(workers, worker)
err = worker.Start()
if err != nil {
t.Errorf("Failed to start worker %d: %v", i, err)
}
t.Logf("Worker %d started on port %d", i, port)
}
time.Sleep(1 * time.Second)
var wg sync.WaitGroup
for i, w := range workers {
wg.Add(1)
go func(idx int, worker *manager.Worker) {
defer wg.Done()
for range 5 {
_ = worker.Status()
_ = worker.Logs()
time.Sleep(50 * time.Millisecond)
}
if worker.IsRunning() {
err := worker.Stop()
if err != nil {
t.Logf("Worker %d stop error: %v", idx, err)
}
}
err := worker.Cleanup()
if err != nil {
t.Logf("Worker %d cleanup error: %v", idx, err)
}
}(i, w)
}
wg.Wait()
}
func TestManager_StressStartStopCycle(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
mgr := manager.NewManager(args)
defer mgr.Cleanup()
iterations := 10
for i := range iterations {
t.Logf("Manager cycle %d/%d", i+1, iterations)
err := mgr.Start()
if err != nil {
t.Errorf("Start error at iteration %d: %v", i+1, err)
}
time.Sleep(300 * time.Millisecond)
if mgr.IsRunning() {
err = mgr.Stop()
if err != nil {
t.Errorf("Stop error at iteration %d: %v", i+1, err)
}
}
time.Sleep(200 * time.Millisecond)
}
}
func TestManager_StressRestart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
if err != nil {
t.Fatalf("Failed initial start: %v", err)
}
time.Sleep(500 * time.Millisecond)
iterations := 10
for i := range iterations {
t.Logf("Manager restart %d/%d", i+1, iterations)
err := mgr.Restart()
if err != nil {
t.Errorf("Restart error at iteration %d: %v", i+1, err)
}
time.Sleep(400 * time.Millisecond)
}
if mgr.IsRunning() {
mgr.Stop()
}
}
func TestWorker_StressConcurrentOperations(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
worker := manager.NewWorker(args)
defer worker.Cleanup()
err = worker.Start()
if err != nil {
t.Fatalf("Failed to start worker: %v", err)
}
time.Sleep(500 * time.Millisecond)
var wg sync.WaitGroup
done := make(chan struct{})
for range 5 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
_ = worker.Status()
time.Sleep(10 * time.Millisecond)
}
}
}()
}
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
_ = worker.Logs()
time.Sleep(20 * time.Millisecond)
}
}
}()
}
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
_ = worker.Status()
time.Sleep(30 * time.Millisecond)
}
}
}()
}
time.Sleep(3 * time.Second)
close(done)
wg.Wait()
if worker.IsRunning() {
worker.Stop()
}
}
func TestManager_StressMultipleInstances(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
managerCount := 3
managers := make([]*manager.Manager, 0, managerCount)
for i := range managerCount {
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args := manager.NewWorkerArgs()
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "error"
mgr := manager.NewManager(args)
managers = append(managers, mgr)
err = mgr.Start()
if err != nil {
t.Errorf("Failed to start manager %d: %v", i, err)
}
t.Logf("Manager %d started on port %d", i, port)
time.Sleep(500 * time.Millisecond)
}
time.Sleep(1 * time.Second)
var wg sync.WaitGroup
for i, m := range managers {
wg.Add(1)
go func(idx int, mgr *manager.Manager) {
defer wg.Done()
for range 3 {
_ = mgr.IsRunning()
_ = mgr.Status()
_ = mgr.Logs()
time.Sleep(100 * time.Millisecond)
}
if mgr.IsRunning() {
err := mgr.Stop()
if err != nil {
t.Logf("Manager %d stop error: %v", idx, err)
}
}
err := mgr.Cleanup()
if err != nil {
t.Logf("Manager %d cleanup error: %v", idx, err)
}
}(i, m)
}
wg.Wait()
}

View File

@@ -0,0 +1,219 @@
package manager_test
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/xxnuo/MTranServer/bin"
"github.com/xxnuo/MTranServer/internal/manager"
"github.com/xxnuo/MTranServer/internal/utils"
)
func TestBasicUsage(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
args := manager.NewWorkerArgs()
args.Host = "127.0.0.1"
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.EnableWebSocket = true
args.EnableHTTP = true
args.LogLevel = "debug"
args.WorkDir = "."
worker := manager.NewWorker(args)
defer worker.Cleanup()
if err := worker.Start(); err != nil {
t.Logf("Failed to start worker: %v\n", err)
t.Fatalf("Failed to start worker: %v", err)
}
time.Sleep(2 * time.Second)
status := worker.Status()
t.Logf("Worker status: %s\n", status)
if worker.IsRunning() {
t.Log("Worker is running")
}
logs := worker.Logs()
t.Logf("Collected %d log lines\n", len(logs))
for _, log := range logs {
t.Log(log)
}
if err := worker.Restart(); err != nil {
t.Logf("Failed to restart worker: %v\n", err)
t.Fatalf("Failed to restart worker: %v", err)
}
time.Sleep(2 * time.Second)
if err := worker.Stop(); err != nil {
t.Logf("Failed to stop worker: %v\n", err)
t.Fatalf("Failed to stop worker: %v", err)
}
t.Log("Worker stopped successfully")
}
func TestLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
worker := manager.NewWorker(args)
defer worker.Cleanup()
worker.Start()
time.Sleep(1 * time.Second)
t.Log("Status:", worker.Status())
worker.Stop()
time.Sleep(500 * time.Millisecond)
t.Log("Status after stop:", worker.Status())
}
func TestWorkerHash(t *testing.T) {
if bin.WorkerHash == "" {
t.Fatal("WorkerHash should not be empty")
}
if len(bin.WorkerHash) != 64 {
t.Fatalf("WorkerHash should be 64 characters (SHA256), got %d", len(bin.WorkerHash))
}
t.Logf("Worker binary hash: %s", bin.WorkerHash)
t.Logf("Worker binary size: %d bytes", len(bin.WorkerBinary))
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
worker := manager.NewWorker(args)
defer worker.Cleanup()
if err := worker.Start(); err != nil {
t.Fatalf("Failed to start worker on first attempt: %v", err)
}
time.Sleep(1 * time.Second)
worker.Stop()
time.Sleep(500 * time.Millisecond)
if err := worker.Start(); err != nil {
t.Fatalf("Failed to start worker on second attempt: %v", err)
}
time.Sleep(1 * time.Second)
worker.Stop()
time.Sleep(500 * time.Millisecond)
t.Log("Worker hash verification successful")
}
func TestCustomBinaryPath(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
customPath := filepath.Join(os.TempDir(), "custom-mtran-worker")
if runtime.GOOS == "windows" {
customPath += ".exe"
}
if err := os.WriteFile(customPath, bin.WorkerBinary, 0755); err != nil {
t.Fatalf("Failed to create custom binary: %v", err)
}
defer os.Remove(customPath)
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args.Port = port
args.BinaryPath = customPath
worker := manager.NewWorker(args)
defer worker.Cleanup()
if err := worker.Start(); err != nil {
t.Fatalf("Failed to start worker with custom binary path: %v", err)
}
time.Sleep(1 * time.Second)
if !worker.IsRunning() {
t.Fatal("Worker should be running")
}
worker.Stop()
time.Sleep(500 * time.Millisecond)
t.Log("Worker with custom binary path stopped successfully")
}
func TestMultipleWorkers(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
workers := make([]*manager.Worker, 0, 3)
for i := 0; i < 3; i++ {
port, err := utils.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port: %v", err)
}
args := manager.NewWorkerArgs()
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
worker := manager.NewWorker(args)
workers = append(workers, worker)
if err := worker.Start(); err != nil {
t.Fatalf("Failed to start worker %d: %v", i, err)
}
t.Logf("Worker %d started on port %d", i, port)
}
time.Sleep(2 * time.Second)
for i, worker := range workers {
if !worker.IsRunning() {
t.Errorf("Worker %d should be running", i)
}
t.Logf("Worker %d: Status=%s", i, worker.Status())
}
for i, worker := range workers {
if err := worker.Stop(); err != nil {
t.Errorf("Failed to stop worker %d: %v", i, err)
}
worker.Cleanup()
t.Logf("Worker %d stopped", i)
}
time.Sleep(500 * time.Millisecond)
t.Log("All workers stopped successfully")
}

View File

@@ -0,0 +1,422 @@
package manager
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/xxnuo/MTranServer/internal/logger"
)
const (
StateStopped = 0
StateStarting = 1
StateRunning = 2
StateRestarting = 3
)
type Manager struct {
worker *Worker
client *Client
mu sync.RWMutex
url string
taskQueue chan struct{} // Token bucket for serializing tasks
closed bool
state int
}
type ManagerOption func(*Manager)
func NewManager(args *WorkerArgs, opts ...ManagerOption) *Manager {
url := fmt.Sprintf("ws://%s:%d/ws", args.Host, args.Port)
m := &Manager{
worker: NewWorker(args),
url: url,
taskQueue: make(chan struct{}, 1),
state: StateStopped,
}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *Manager) Start() error {
m.mu.Lock()
if m.state != StateStopped {
m.mu.Unlock()
return fmt.Errorf("manager is not in stopped state")
}
m.state = StateStarting
if err := m.worker.Start(); err != nil {
m.state = StateStopped
m.mu.Unlock()
return fmt.Errorf("failed to start worker: %w", err)
}
m.mu.Unlock()
timeout := time.After(10 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
var connected bool
var client *Client
for {
select {
case <-timeout:
m.Stop()
return fmt.Errorf("worker start timeout")
case <-ticker.C:
if m.worker.IsRunning() {
if !connected {
client = NewClient(m.url)
if err := client.Connect(); err != nil {
// Keep retrying connection
continue
}
connected = true
}
// Wait for worker to be stable and really ready
healthCtx, healthCancel := context.WithTimeout(context.Background(), 1*time.Second)
isHealthy, _ := client.Health(healthCtx)
healthCancel()
if !isHealthy {
continue
}
m.mu.Lock()
m.client = client
m.state = StateRunning
m.mu.Unlock()
return nil
}
}
}
}
func (m *Manager) Stop() error {
m.mu.Lock()
defer m.mu.Unlock()
m.state = StateStopped
var errs []error
if m.client != nil {
if err := m.client.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close client: %w", err))
}
m.client = nil
}
if m.worker != nil {
if err := m.worker.Stop(); err != nil {
errs = append(errs, fmt.Errorf("failed to stop worker: %w", err))
}
}
if len(errs) > 0 {
return fmt.Errorf("stop errors: %v", errs)
}
return nil
}
func (m *Manager) Restart() error {
if err := m.Stop(); err != nil {
return fmt.Errorf("failed to stop: %w", err)
}
time.Sleep(500 * time.Millisecond)
return m.Start()
}
func (m *Manager) Cleanup() error {
m.mu.Lock()
m.state = StateStopped
defer m.mu.Unlock()
var errs []error
if m.client != nil {
if err := m.client.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close client: %w", err))
}
m.client = nil
}
if m.worker != nil {
if err := m.worker.Cleanup(); err != nil {
errs = append(errs, fmt.Errorf("failed to cleanup worker: %w", err))
}
}
if len(errs) > 0 {
return fmt.Errorf("cleanup errors: %v", errs)
}
return nil
}
func (m *Manager) IsRunning() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.state == StateRunning && m.worker != nil && m.worker.IsRunning() && m.client != nil && m.client.IsConnected()
}
func (m *Manager) IsHealthy(ctx context.Context) bool {
m.mu.RLock()
defer m.mu.RUnlock()
if m.state != StateRunning || m.client == nil {
return false
}
healthy, err := m.client.Health(ctx)
return err == nil && healthy
}
func (m *Manager) Status() string {
m.mu.RLock()
defer m.mu.RUnlock()
switch m.state {
case StateStopped:
return "stopped"
case StateStarting:
return "starting"
case StateRunning:
return "running"
case StateRestarting:
return "restarting"
default:
return "unknown"
}
}
func (m *Manager) Logs() []string {
m.mu.RLock()
defer m.mu.RUnlock()
if m.worker == nil {
return nil
}
return m.worker.Logs()
}
func (m *Manager) Health(ctx context.Context) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.client == nil {
return false, fmt.Errorf("client not initialized")
}
return m.client.Health(ctx)
}
func (m *Manager) Trans(ctx context.Context, req TransRequest) (string, error) {
// 1. Check state immediately
m.mu.RLock()
if m.state != StateRunning {
state := m.state
m.mu.RUnlock()
return "", fmt.Errorf("manager not running (state: %d)", state)
}
m.mu.RUnlock()
// 2. Wait for task slot (concurrency control)
select {
case m.taskQueue <- struct{}{}:
defer func() { <-m.taskQueue }()
case <-ctx.Done():
return "", ctx.Err()
}
logger.Debug("Manager.Trans: text length: %d, isHTML: %v", len(req.Text), req.HTML)
m.mu.RLock()
if m.closed {
m.mu.RUnlock()
return "", fmt.Errorf("manager is closed")
}
// Double check state after acquiring lock
if m.state != StateRunning {
m.mu.RUnlock()
return "", fmt.Errorf("manager not running (state: %d)", m.state)
}
client := m.client
// worker := m.worker
m.mu.RUnlock()
if client == nil {
logger.Error("Manager.Trans: client not initialized")
m.TriggerRestartAsync()
return "", fmt.Errorf("client not initialized")
}
logger.Debug("Manager.Trans: calling client.Trans")
result, err := client.Trans(ctx, req)
if err == nil {
logger.Debug("Manager.Trans: success, result length: %d", len(result))
return result, nil
}
logger.Debug("Manager.Trans: client.Trans error: %v", err)
errMsg := err.Error()
isConnectionError := !client.IsConnected() ||
strings.Contains(errMsg, "not connected") ||
strings.Contains(errMsg, "failed to send message") ||
strings.Contains(errMsg, "failed to read response") ||
strings.Contains(errMsg, "module closed") ||
strings.Contains(errMsg, "exit_code") ||
strings.Contains(errMsg, "wasm error") ||
strings.Contains(errMsg, "invalid table access") ||
strings.Contains(errMsg, "Translation engine not ready") ||
strings.Contains(errMsg, "code 503")
if !isConnectionError {
return "", err
}
// Trigger async restart and fail this request
m.TriggerRestartAsync()
return "", fmt.Errorf("worker connection failed, restarting: %w", err)
}
func (m *Manager) TriggerRestartAsync() {
m.mu.Lock()
if m.state == StateRestarting || m.state == StateStopped {
m.mu.Unlock()
return
}
m.state = StateRestarting
m.mu.Unlock()
go func() {
logger.Info("Async restart triggered for worker on port %d", m.worker.args.Port)
if err := m.RestartWorker(); err != nil {
logger.Error("Async restart failed: %v", err)
// Ensure we mark as stopped so it can be picked up or retried later if needed?
// or maybe we should try again? For now, leave it as stopped/failed.
m.mu.Lock()
m.state = StateStopped
m.mu.Unlock()
} else {
logger.Info("Async restart completed successfully")
}
}()
}
// RestartWorker performs the kill-and-restart logic on the SAME port
func (m *Manager) RestartWorker() error {
// 1. Kill old worker and cleanup resources
m.mu.Lock()
oldWorker := m.worker
oldClient := m.client
if oldClient != nil {
m.client = nil
go oldClient.Close() // Close async
}
m.mu.Unlock()
logger.Info("Stopping old worker...")
// Force kill if necessary, make sure port is freed
if oldWorker != nil {
if err := oldWorker.Cleanup(); err != nil {
logger.Warn("Failed to cleanup old worker: %v", err)
}
}
// Wait a bit to ensure OS releases port
time.Sleep(1 * time.Second)
// 2. Start new worker on the SAME port (args are reused)
// We need to create a NEW worker instance because the old one holds the old cmd/process
m.mu.Lock()
newWorker := NewWorker(m.worker.args)
m.worker = newWorker
m.mu.Unlock()
logger.Info("Starting new worker on port %d...", newWorker.args.Port)
if err := newWorker.Start(); err != nil {
return fmt.Errorf("failed to start new worker: %w", err)
}
// 3. Wait for readiness
timeout := time.After(30 * time.Second)
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
var client *Client
var connected bool
for {
select {
case <-timeout:
newWorker.Stop()
return fmt.Errorf("restart timeout waiting for worker readiness")
case <-ticker.C:
if newWorker.IsRunning() {
if !connected {
client = NewClient(m.url) // URL is unchanged
if err := client.Connect(); err != nil {
continue
}
connected = true
}
healthCtx, healthCancel := context.WithTimeout(context.Background(), 1*time.Second)
isHealthy, _ := client.Health(healthCtx)
healthCancel()
if isHealthy {
m.mu.Lock()
m.client = client
m.state = StateRunning
m.mu.Unlock()
return nil
}
}
}
}
}
func (m *Manager) Exit(ctx context.Context, req ExitRequest) (*ExitResponse, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.client == nil {
return nil, fmt.Errorf("client not initialized")
}
return m.client.Exit(ctx, req)
}
func (m *Manager) Translate(ctx context.Context, text string) (string, error) {
return m.Trans(ctx, TransRequest{
Text: text,
HTML: false,
})
}
func (m *Manager) TranslateHTML(ctx context.Context, html string) (string, error) {
return m.Trans(ctx, TransRequest{
Text: html,
HTML: true,
})
}

View File

@@ -0,0 +1,247 @@
package manager_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xxnuo/MTranServer/internal/manager"
"github.com/xxnuo/MTranServer/internal/utils"
)
func TestManager_StartStop(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
require.NoError(t, err)
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
args.LogLevel = "debug"
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
require.NoError(t, err)
assert.True(t, mgr.IsRunning())
assert.Equal(t, "running", mgr.Status())
err = mgr.Stop()
require.NoError(t, err)
assert.False(t, mgr.IsRunning())
}
func TestManager_Restart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
require.NoError(t, err)
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
require.NoError(t, err)
assert.True(t, mgr.IsRunning())
err = mgr.Restart()
require.NoError(t, err)
assert.True(t, mgr.IsRunning())
err = mgr.Stop()
require.NoError(t, err)
}
func TestManager_Logs(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
require.NoError(t, err)
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
require.NoError(t, err)
defer mgr.Stop()
time.Sleep(1 * time.Second)
logs := mgr.Logs()
assert.NotEmpty(t, logs)
t.Logf("Collected %d log lines", len(logs))
}
func TestManager_Translate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
require.NoError(t, err)
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
require.NoError(t, err)
defer mgr.Stop()
time.Sleep(1 * time.Second)
ctx := context.Background()
_, err = mgr.Translate(ctx, "Hello")
assert.Error(t, err)
}
func TestManager_TranslateHTML(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
require.NoError(t, err)
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
require.NoError(t, err)
defer mgr.Stop()
time.Sleep(1 * time.Second)
ctx := context.Background()
_, err = mgr.TranslateHTML(ctx, "<p>Hello</p>")
assert.Error(t, err)
}
func TestManager_MultipleManagers(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
managers := make([]*manager.Manager, 0, 3)
for i := 0; i < 3; i++ {
port, err := utils.GetFreePort()
require.NoError(t, err)
args := manager.NewWorkerArgs()
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
mgr := manager.NewManager(args)
managers = append(managers, mgr)
err = mgr.Start()
require.NoError(t, err)
t.Logf("Manager %d started on port %d", i, port)
}
time.Sleep(2 * time.Second)
for i, mgr := range managers {
assert.True(t, mgr.IsRunning(), "Manager %d should be running", i)
}
for i, mgr := range managers {
err := mgr.Stop()
assert.NoError(t, err)
mgr.Cleanup()
t.Logf("Manager %d stopped", i)
}
}
func TestManager_NotStarted(t *testing.T) {
args := manager.NewWorkerArgs()
mgr := manager.NewManager(args)
defer mgr.Cleanup()
ctx := context.Background()
_, err := mgr.Health(ctx)
assert.Error(t, err)
_, err = mgr.Translate(ctx, "Hello")
assert.Error(t, err)
assert.False(t, mgr.IsRunning())
}
func TestManager_FullWorkflow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
t.Skip("Requires real model files")
args := manager.NewWorkerArgs()
port, err := utils.GetFreePort()
require.NoError(t, err)
args.Port = port
args.Host = "127.0.0.1"
args.EnableWebSocket = true
mgr := manager.NewManager(args)
defer mgr.Cleanup()
err = mgr.Start()
require.NoError(t, err)
defer mgr.Stop()
time.Sleep(1 * time.Second)
ctx := context.Background()
ready, err := mgr.Health(ctx)
require.NoError(t, err)
assert.True(t, ready)
result, err := mgr.Translate(ctx, "Hello, world!")
require.NoError(t, err)
assert.NotEmpty(t, result)
t.Logf("Translation result: %s", result)
htmlResult, err := mgr.TranslateHTML(ctx, "<p>Hello, world!</p>")
require.NoError(t, err)
assert.NotEmpty(t, htmlResult)
t.Logf("HTML translation result: %s", htmlResult)
exitResp, err := mgr.Exit(ctx, manager.ExitRequest{
Force: true,
})
require.NoError(t, err)
assert.NotNil(t, exitResp)
}

View File

@@ -0,0 +1,137 @@
package manager
import (
"context"
"sync"
"time"
"github.com/xxnuo/MTranServer/internal/logger"
)
type WorkerMonitor struct {
worker *Worker
client *Client
url string
stopChan chan struct{}
restartChan chan struct{}
wg sync.WaitGroup
mu sync.Mutex
running bool
}
func NewWorkerMonitor(worker *Worker, client *Client, url string) *WorkerMonitor {
return &WorkerMonitor{
worker: worker,
client: client,
url: url,
stopChan: make(chan struct{}),
restartChan: make(chan struct{}, 1),
running: false,
}
}
func (wm *WorkerMonitor) Start() {
wm.mu.Lock()
if wm.running {
wm.mu.Unlock()
return
}
wm.running = true
wm.mu.Unlock()
wm.wg.Add(1)
go wm.monitor()
}
func (wm *WorkerMonitor) Stop() {
wm.mu.Lock()
if !wm.running {
wm.mu.Unlock()
return
}
wm.running = false
wm.mu.Unlock()
close(wm.stopChan)
wm.wg.Wait()
}
func (wm *WorkerMonitor) monitor() {
defer wm.wg.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-wm.stopChan:
return
case <-wm.restartChan:
wm.handleRestart()
case <-ticker.C:
wm.checkWorkerHealth()
}
}
}
func (wm *WorkerMonitor) checkWorkerHealth() {
if !wm.worker.IsRunning() {
logger.Warn("Worker process exited unexpectedly, attempting restart...")
select {
case wm.restartChan <- struct{}{}:
default:
}
return
}
if wm.client != nil && wm.client.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := wm.client.Health(ctx)
if err != nil {
logger.Warn("Worker health check failed: %v, attempting restart...", err)
select {
case wm.restartChan <- struct{}{}:
default:
}
}
}
}
func (wm *WorkerMonitor) handleRestart() {
logger.Info("Restarting worker...")
if wm.client != nil {
wm.client.Close()
}
if err := wm.worker.Stop(); err != nil {
logger.Warn("Failed to stop worker cleanly: %v", err)
}
time.Sleep(1 * time.Second)
if err := wm.worker.Start(); err != nil {
logger.Error("Failed to restart worker: %v", err)
return
}
newClient := NewClient(wm.url)
if err := newClient.Connect(); err != nil {
logger.Error("Failed to reconnect client after restart: %v", err)
return
}
wm.client = newClient
logger.Info("Worker restarted successfully")
}
func (wm *WorkerMonitor) TriggerRestart() {
select {
case wm.restartChan <- struct{}{}:
default:
}
}

View File

@@ -0,0 +1,37 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/xxnuo/MTranServer/internal/logger"
)
func Auth(apiToken string) gin.HandlerFunc {
return func(c *gin.Context) {
if apiToken == "" {
c.Next()
return
}
token := c.GetHeader("Authorization")
if token != "" {
token = strings.TrimPrefix(token, "Bearer ")
} else {
token = c.Query("token")
}
if token != apiToken {
logger.Warn("Unauthorized access attempt from %s to %s", c.ClientIP(), c.Request.URL.Path)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
c.Abort()
return
}
c.Next()
}
}

View File

@@ -0,0 +1,80 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestAuthWithValidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Auth("test-token"))
r.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "test-token")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "success", w.Body.String())
}
func TestAuthWithValidTokenInQuery(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Auth("test-token"))
r.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test?token=test-token", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "success", w.Body.String())
}
func TestAuthWithInvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Auth("test-token"))
r.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "wrong-token")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "Unauthorized")
}
func TestAuthWithNoToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Auth("test-token"))
r.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "Unauthorized")
}

View File

@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, KEY")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,46 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestCORS(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CORS())
r.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "test")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin"))
assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials"))
assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Headers"))
assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Methods"))
}
func TestCORSOptions(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CORS())
r.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "test")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("OPTIONS", "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}

Some files were not shown because too many files have changed in this diff Show More