diff --git a/.github/chatmodes/readme-generator.chatmode.md b/.github/chatmodes/readme-generator.chatmode.md new file mode 100644 index 00000000..76bd7db7 --- /dev/null +++ b/.github/chatmodes/readme-generator.chatmode.md @@ -0,0 +1,56 @@ +--- +description: 'プロジェクト解析に基づくREADME自動生成専用モード' +tools: ['edit', 'search', 'runCommands', 'runTasks', 'usages', 'vscodeAPI', 'problems', 'changes', 'fetch', 'ms-python.python/getPythonEnvironmentInfo', 'ms-python.python/getPythonExecutableCommand', 'ms-python.python/installPythonPackage', 'ms-python.python/configurePythonEnvironment', 'todos'] +model: 'Auto' +--- + +# README自動生成モード ※下記コードでは絵文字をカットしています + +あなたは優秀な技術文書ライターです。プロジェクトのコードベースを詳細に分析し、正確で包括的なREADMEファイルを生成することが役割です。 + +## 基本方針 ※下記コードでは絵文字をカットしています + +### 分析対象 +- プロジェクトの全ファイル構成 +- 主要なソースコード(HTML、JavaScript、CSS、Python等) +- 設定ファイル(package.json、requirements.txt等) +- 既存のドキュメント + +### README構成要件 ※下記コードでは絵文字をカットしています + +必須セクション(この順序で記載): +1. **プロジェクト概要** - 1-2文でプロジェクトの目的を説明 +2. **主要機能** - 実装されている機能のリスト +3. **使用方法** - エンドユーザー向けの操作手順 +4. **動作環境** - 必要な環境・依存関係 +5. **インストール・実行方法** - 開発者向けセットアップ手順 +6. **ファイル構成** - 主要ファイルの説明 +7. **制限事項・既知の問題** - 現在できないこと、バグ +8. **技術仕様** - 使用技術・ライブラリ +9. **ライセンス** - 適用されるライセンス情報 + +### 重要な制約事項 ※下記コードでは絵文字をカットしています + +1. **事実のみ記載**: コードベースに存在しない機能は記載しない +2. **検証可能な情報**: 実際のファイルから確認できる内容のみ +3. **ユーザー目線**: エンドユーザーが最初に知りたい情報を優先 +4. **簡潔で明確**: 冗長な説明は避け、必要な情報を効率的に伝達 +5. **制限事項の明記**: 実装されていない機能や既知の問題を正直に記載 + +### 文体・フォーマット ※下記コードでは絵文字をカットしています + +- 敬語は使用せず、簡潔で読みやすい文体 +- コードブロックやリストを適切に活用 +- 見出しは階層的に整理 +- 技術用語は必要に応じて補足説明 + +### 分析手順 ※下記コードでは絵文字をカットしています + +1. プロジェクトの全体構造を把握 +2. メインファイル(index.html、main.js等)の機能を解析 +3. 設定ファイルから依存関係や環境要件を抽出 +4. 実際の動作フローを追跡 +5. 未実装部分や制限事項を特定 +6. 上記構成要件に従ってREADMEを生成 + +コードベースの事実に基づいた、正確で有用なREADMEファイルの生成を最優先に作業してください。 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6ff13fd6..8019c521 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ error.log VRCT.zip VRCT_cuda.zip +.qoder/ + # Added by WebUI migration # Logs logs @@ -45,4 +47,5 @@ dist-ssr # Customize /build -error.txt \ No newline at end of file +error.txt +config.json \ No newline at end of file diff --git a/bat/build.bat b/bat/build.bat new file mode 100644 index 00000000..7760b7ee --- /dev/null +++ b/bat/build.bat @@ -0,0 +1,2 @@ +call .venv/Scripts/activate +pyinstaller spec/backend.spec --distpath src-tauri/bin --clean --noconfirm --log-level ERROR \ No newline at end of file diff --git a/bat/build_cuda.bat b/bat/build_cuda.bat new file mode 100644 index 00000000..0c86f70d --- /dev/null +++ b/bat/build_cuda.bat @@ -0,0 +1,2 @@ +call .venv_cuda/Scripts/activate +pyinstaller spec/backend_cuda.spec --distpath src-tauri/bin --clean --noconfirm --log-level ERROR \ No newline at end of file diff --git a/install.bat b/bat/install.bat similarity index 100% rename from install.bat rename to bat/install.bat diff --git a/build.bat b/build.bat deleted file mode 100644 index 3c08629c..00000000 --- a/build.bat +++ /dev/null @@ -1,2 +0,0 @@ -call .venv/Scripts/activate -pyinstaller backend.spec --distpath src-tauri/bin --clean --noconfirm --log-level ERROR \ No newline at end of file diff --git a/build_cuda.bat b/build_cuda.bat deleted file mode 100644 index 308b9174..00000000 --- a/build_cuda.bat +++ /dev/null @@ -1,2 +0,0 @@ -call .venv_cuda/Scripts/activate -pyinstaller backend_cuda.spec --distpath src-tauri/bin --clean --noconfirm --log-level ERROR \ No newline at end of file diff --git a/clean.py b/clean.py deleted file mode 100644 index 93084e62..00000000 --- a/clean.py +++ /dev/null @@ -1,6 +0,0 @@ -import shutil - -shutil.rmtree('build', ignore_errors=True) -shutil.rmtree('dist', ignore_errors=True) -shutil.rmtree('src-tauri\\bin', ignore_errors=True) -shutil.rmtree('src-tauri\\target', ignore_errors=True) \ No newline at end of file diff --git a/docs/readme_build.md b/docs/readme_build.md new file mode 100644 index 00000000..644c7b4c --- /dev/null +++ b/docs/readme_build.md @@ -0,0 +1,422 @@ +# VRCTビルドガイド + +このドキュメントでは、VRCTプロジェクトのビルド方法について説明します。 + +## 目次 + +- [必要な環境](#必要な環境) +- [初回セットアップ](#初回セットアップ) +- [ビルドの種類](#ビルドの種類) +- [開発ビルド](#開発ビルド) +- [リリースビルド](#リリースビルド) +- [ビルドプロセスの詳細](#ビルドプロセスの詳細) +- [トラブルシューティング](#トラブルシューティング) + +## 必要な環境 + +### 必須ソフトウェア + +- **Node.js** (npm含む) +- **Python 3.x** +- **Rust** (Tauri用) +- **Git** + +### 推奨環境 + +- Windows 10/11 +- メモリ: 8GB以上 +- ストレージ: 5GB以上の空き容量 + +## 初回セットアップ + +### 1. リポジトリのクローン + +```bash +git clone +cd VRCT +``` + +### 2. Node.js依存関係のインストール + +```bash +npm install +``` + +### 3. Python環境のセットアップ + +以下のコマンドで、CPU版とCUDA版の両方の仮想環境を作成します: + +```bash +npm run setup-python +``` + +このコマンドは以下の処理を実行します: +- `.venv` (CPU版) の作成と依存関係のインストール +- `.venv_cuda` (CUDA版) の作成と依存関係のインストール + +> **注意**: CUDA版を使用する場合は、NVIDIAのGPUとCUDA Toolkit 12.8が必要です。 + +## ビルドの種類 + +VRCTでは、以下の2種類のビルドが可能です: + +### CPU版 +標準的なCPUで動作するバージョン。GPUは不要。 + +### CUDA版 +NVIDIA GPUを活用した高速処理版。CUDA対応GPUが必要。 + +## 開発ビルド + +開発中にアプリケーションを実行・テストするためのビルドです。 + +### CPU版の開発ビルド + +```bash +npm run dev +``` + +このコマンドは以下を実行します: +1. 実行中のプロセスを終了 (`task-kill`) +2. ビルドファイルのクリーンアップ (`clean`) +3. バージョン情報の更新 (`update-version`) +4. Pythonバックエンドのビルド (`build-python`) +5. ViteとTauriの開発サーバー起動 + +### CUDA版の開発ビルド + +```bash +npm run dev-cuda +``` + +CPU版と同様ですが、CUDA対応のPythonバックエンドをビルドします。 + +### UIのみの開発 + +バックエンドのビルドをスキップして、UIのみを開発する場合: + +```bash +npm run dev-ui +``` + +## リリースビルド + +配布用のインストーラーを作成するビルドです。 + +### CPU版のリリースビルド + +```bash +npm run build +``` + +または、ZIP形式でパッケージング: + +```bash +npm run release +``` + +生成されるファイル: +- インストーラー: `src-tauri/target/release/bundle/nsis/` +- ZIPファイル: `VRCT.zip` (releaseコマンド使用時) + +### CUDA版のリリースビルド + +```bash +npm run build-cuda +``` + +または、ZIP形式でパッケージング: + +```bash +npm run release-cuda +``` + +生成されるファイル: +- インストーラー: `src-tauri/target/release/bundle/nsis/` +- ZIPファイル: `VRCT_cuda.zip` (release-cudaコマンド使用時) + +### 両バージョンの同時ビルド + +CPU版とCUDA版の両方をビルドする場合: + +```bash +npm run release-all +``` + +## ビルドプロセスの詳細 + +### バージョン管理 + +バージョンは `package.json` で一元管理され、以下のファイルに自動で同期されます: + +```bash +npm run update-version +``` + +更新されるファイル: +- `src-tauri/tauri.conf.json` +- `src-python/config.py` + +### どこにバージョンを設定すればReleaseに反映されるか + +- **設定箇所**: `package.json` の `version` が唯一のソース・オブ・トゥルース。 +- **反映方法**: `npm run update-version`(`build`/`build-cuda`/`release`コマンド内でも自動実行)により、 + - `src-tauri/tauri.conf.json` の `version` に同期(Tauri/NSISインストーラーの表示・メタデータに使用) + - `src-python/config.py` の `self._VERSION` に同期(ランタイム表示等に使用) +- **成果物への影響**: + - インストーラー(NSIS)は `tauri.conf.json` の `version` を取り込み、プロダクトバージョンとして反映。 + - ZIPパッケージ名はスクリプト既定では固定(`VRCT.zip`/`VRCT_cuda.zip`)。ファイル名にバージョンを含めたい場合は、`package.json` の `release` スクリプトを調整してください。 + +### Pythonバックエンドのビルド + +#### CPU版 + +```bash +npm run build-python +``` + +実行内容: + +- `.venv` 環境をアクティベート +- PyInstallerで `spec/backend.spec` を使用してビルド +- 出力先: `src-tauri/bin/` + +#### CUDA版 + +```bash +npm run build-python-cuda +``` + +実行内容: + +- `.venv_cuda` 環境をアクティベート +- PyInstallerで `spec/backend_cuda.spec` を使用してビルド +- 出力先: `src-tauri/bin/` + +### フロントエンドのビルド + +```bash +npm run vite-build +``` + +Viteを使用してフロントエンド(React)をビルドし、`dist/` ディレクトリに出力します。 + +### Tauriアプリケーションのビルド + +```bash +npm run tauri build +``` + +Tauriを使用して最終的なデスクトップアプリケーションをビルドします。 + +## GitHub ActionsでのRelease自動化 + +Windows用のReleaseをGitHub Actionsで自動生成・公開する例です。`package.json` のバージョンをタグ・リリース名に使い、TauriのNSISインストーラーとZIPを添付します。 + +### 推奨トリガー + +- タグプッシュ(例: `v*`)または手動実行(`workflow_dispatch`) + +### サンプルワークフロー(Windows) + +```yaml +name: Release (Windows) + +on: + workflow_dispatch: {} + push: + tags: + - 'v*' + +jobs: + build-release-windows: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: | + npm ci + + - name: Setup Python envs (.venv/.venv_cuda) + run: | + npm run setup-python + + - name: Sync versions from package.json + run: | + npm run update-version + + - name: Build (CPU) + run: | + npm run build + + - name: Package ZIP (CPU) + run: | + python utils/zip.py --zip_name VRCT.zip + + - name: Read version from package.json + id: pkg + shell: pwsh + run: | + $version = (Get-Content package.json | ConvertFrom-Json).version + echo "version=$version" >> $env:GITHUB_OUTPUT + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: VRCT-windows-${{ steps.pkg.outputs.version }} + path: | + src-tauri/target/release/bundle/nsis/**/* + VRCT.zip + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.pkg.outputs.version }} + name: VRCT v${{ steps.pkg.outputs.version }} + files: | + src-tauri/target/release/bundle/nsis/**/* + VRCT.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +### ポイント +- ビルド前に必ず `npm run update-version` を実行して、`tauri.conf.json` と `config.py` にバージョンを同期します。 +- アーティファクトのパスは既定構成に合わせています: + - インストーラー: `src-tauri/target/release/bundle/nsis/` + - ZIP: ルート直下の `VRCT.zip` +- CUDA版も同様にビルドする場合は、`npm run build-cuda` と `python utils/zip.py --zip_name VRCT_cuda.zip` を追加して、別アーティファクト名でアップロード・添付してください。 + +## ユーティリティコマンド + +### クリーンアップ + +```bash +npm run clean +``` + +以下のディレクトリを削除します: +- `build/` +- `dist/` +- `src-tauri/bin/` +- `src-tauri/target/` + +### プロセスの強制終了 + +```bash +npm run task-kill +``` + +VRCTに関連する実行中のプロセスを終了します。 + +## ディレクトリ構成 + +``` +VRCT/ +├── bat/ # バッチスクリプト +│ ├── build.bat # CPU版Pythonビルド +│ ├── build_cuda.bat # CUDA版Pythonビルド +│ └── install.bat # Python環境セットアップ +├── spec/ # PyInstallerスペックファイル +│ ├── backend.spec # CPU版ビルド設定 +│ └── backend_cuda.spec # CUDA版ビルド設定 +├── src-python/ # Pythonバックエンドソースコード +├── src-tauri/ # Tauriアプリケーション設定 +│ ├── bin/ # ビルド済みPythonバイナリ(生成) +│ └── target/ # Tauriビルド出力(生成) +├── src-ui/ # Reactフロントエンドソースコード +├── utils/ # ユーティリティスクリプト +│ ├── clean.py # クリーンアップスクリプト +│ ├── task_kill.py # プロセス終了スクリプト +│ ├── update_version.py # バージョン更新スクリプト +│ └── zip.py # ZIPパッケージング +├── package.json # Node.js設定とバージョン管理 +├── requirements.txt # Python依存関係(CPU版) +└── requirements_cuda.txt # Python依存関係(CUDA版) +``` + +## トラブルシューティング + +### Python環境のエラー + +仮想環境を再作成してください: + +```bash +npm run setup-python +``` + +### ビルドが失敗する + +1. クリーンアップを実行: +```bash +npm run clean +``` + +2. Node.js依存関係を再インストール: +```bash +npm install +``` + +3. 再度ビルド: +```bash +npm run build +``` + +### CUDA版が動作しない + +- CUDA Toolkit 12.8がインストールされているか確認 +- NVIDIA GPUドライバーが最新か確認 +- `requirements_cuda.txt` の依存関係が正しくインストールされているか確認 + +### プロセスが残っている + +```bash +npm run task-kill +``` + +を実行して、すべてのVRCTプロセスを終了してください。 + +## 参考情報 + +### PyInstallerスペックファイル + +- `spec/backend.spec` - CPU版の設定 +- `spec/backend_cuda.spec` - CUDA版の設定 + +これらのファイルでは、以下を設定しています: +- エントリーポイント: `src-python/mainloop.py` +- データファイル(フォント、プロンプト、言語ファイル等)のパス +- 依存ライブラリのパス + +### バージョン管理フロー + +1. `package.json` のバージョンを更新 +2. `npm run update-version` を実行 +3. 自動的に `tauri.conf.json` と `config.py` が更新される + +### リリースパッケージの内容 + +ZIPファイルには以下が含まれます: +- `VRCT.exe` - メインアプリケーション +- `VRCT-sidecar.exe` - Pythonバックエンド +- `_internal/` - 必要な依存ファイル + +## ライセンス + +プロジェクトのライセンスについては、`LICENSE` ファイルを参照してください。 diff --git a/docs/readmes/README.en.md b/docs/readmes/README.en.md index 4618e9ff..ac0b892a 100644 --- a/docs/readmes/README.en.md +++ b/docs/readmes/README.en.md @@ -35,12 +35,6 @@ Become a VRCT Supporter on:    - - - Ko-fi - -   -
@@ -82,7 +76,7 @@ VRCT supports your conversations with # Documents Initial setup, basic functions, and other features are also described. -- [Documents Link](https://mzsoftware.notion.site/VRCT-Documents-be79b7a165f64442ad8f326d86c22246?pvs=4) +- [Documents Link](https://misyaguziya.github.io/VRCT-Docs/) # How to Use (YouTube)
diff --git a/docs/readmes/README.ja.md b/docs/readmes/README.ja.md index 86efe858..c53a736a 100644 --- a/docs/readmes/README.ja.md +++ b/docs/readmes/README.ja.md @@ -35,12 +35,6 @@ Become a VRCT Supporter on:    - - - Ko-fi - -   -
@@ -82,7 +76,7 @@ VRCTはあなたの会話を以下でサポートをします。 # ドキュメント 初期設定や基本機能、その他の機能についても記載してあります。 -- [Documents Link](https://mzsoftware.notion.site/VRCT-Documents-be79b7a165f64442ad8f326d86c22246?pvs=4) +- [Documents Link](https://misyaguziya.github.io/VRCT-Docs/) # 使い方(Youtube)
diff --git a/docs/readmes/README.ko.md b/docs/readmes/README.ko.md index d09e9d36..69d74d38 100644 --- a/docs/readmes/README.ko.md +++ b/docs/readmes/README.ko.md @@ -35,12 +35,6 @@ Become a VRCT Supporter on:    - - - Ko-fi - -   -
@@ -82,7 +76,7 @@ VRCT는 다음과 같이 당신의 대화를 도와드려요. # 문서 (일본어) 초기 설정과 기본 기능 및 기타 기능에 대해 설명되어 있어요. -- [Documents Link](https://mzsoftware.notion.site/VRCT-Documents-be79b7a165f64442ad8f326d86c22246?pvs=4) +- [Documents Link](https://misyaguziya.github.io/VRCT-Docs/) # 사용법 (Youtube)
diff --git a/docs/readmes/README.zh-Hant.md b/docs/readmes/README.zh-Hant.md index 05a8fece..d7210ab6 100644 --- a/docs/readmes/README.zh-Hant.md +++ b/docs/readmes/README.zh-Hant.md @@ -35,12 +35,6 @@ Become a VRCT Supporter on:    - - - Ko-fi - -   -
@@ -81,7 +75,7 @@ VRCT 可以: # 文件 解釋了初始設定、基本功能以及其他功能。 -- [文件(日語)](https://mzsoftware.notion.site/VRCT-Documents-be79b7a165f64442ad8f326d86c22246?pvs=4) +- [Documents Link](https://misyaguziya.github.io/VRCT-Docs/) # YouTube 教學(日語、英文字幕)
diff --git a/locales/en.yml b/locales/en.yml index 8215ce6a..1b08a31b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -76,6 +76,13 @@ config_page: common: version: "Version {{version}}" model_download_button_label: "Download" + correct_auth_key_required: "Correct Auth Key Required" + open_auth_key_webpage: "Open Auth Key Management Webpage" + connection_check: + button_label: "Connection Check" + checking: "Checking..." + connected: "Connected" + disconnected: "Disconnected" compute_device: desc: "The accuracy and speed of each processing type may vary depending on your machine specs, and the compatibility with calculation methods may differ from the displayed order. Please use this as a general guideline." label_device: "Processing Device" @@ -83,6 +90,8 @@ config_page: type_template_auto: "Automatic" type_template_low: "{{type_name}} (Lower accuracy, faster processing)" type_template_high: "{{type_name}} (Higher accuracy, slower processing)" + warning_labels: + unable_to_use_osc_query: "Due to the OSC IP Address settings, OSC data cannot be received, so this feature is currently unavailable." side_menu_labels: device: "Device" @@ -144,6 +153,7 @@ config_page: large: "High Accuracy Model ({{capacity}})" translation_compute_device: label: "Processing device for AI translation" + deepl_auth_key: label: "DeepL Auth Key" desc: "When using it, please change {{translator}} on the main screen to DeepL_API. ※Some languages may not be supported." @@ -152,6 +162,43 @@ config_page: edit: "Edit" auth_key_success: "Auth key update completed." + plamo_auth_key: + label: "Plamo Auth Key" + select_plamo_model: + label: "Select Plamo Model" + + gemini_auth_key: + label: "Gemini Auth Key" + select_gemini_model: + label: "Select Gemini Model" + + openai_auth_key: + label: "OpenAI Auth Key" + select_openai_model: + label: "Select OpenAI Model" + + groq_auth_key: + label: "Groq Auth Key" + select_groq_model: + label: "Select Groq Model" + + openrouter_auth_key: + label: "OpenRouter Auth Key" + select_openrouter_model: + label: "Select OpenRouter Model" + + lmstudio_connection_check: + label: "Check LM Studio Connection" + select_lmstudio_model: + label: "Select LMStudio Model" + connection_required: "LMStudio Connection Required" + + ollama_connection_check: + label: "Check Ollama Connection" + select_ollama_model: + label: "Select Ollama Model" + connection_required: "Ollama Connection Required" + transcription: section_label_mic: "Mic" section_label_speaker: "Speaker" @@ -268,7 +315,6 @@ config_page: convert_message_to_hiragana: label: "Show Hiragana" - hotkeys: toggle_vrct_visibility: label: "Toggle VRCT visibility" diff --git a/locales/ja.yml b/locales/ja.yml index 90117091..51ae19f4 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -76,6 +76,13 @@ config_page: common: version: "バージョン {{version}}" model_download_button_label: "ダウンロード" + correct_auth_key_required: "正しい認証キーが必要" + open_auth_key_webpage: "認証キー管理ページを開く" + connection_check: + button_label: "接続確認" + checking: "確認中..." + connected: "接続済み" + disconnected: "未接続" compute_device: desc: "各処理タイプの精度・速度は、マシンスペックによって計算方法に相性があり、表示順とは異なる事があるため、大まかな目安としてください。" label_device: "処理デバイス" @@ -83,6 +90,8 @@ config_page: type_template_auto: "自動" type_template_low: "{{type_name}} (精度が悪く、処理は早い)" type_template_high: "{{type_name}} (精度が良く、処理は遅い)" + warning_labels: + unable_to_use_osc_query: "OSC IP Address の設定によりOSCデータの受信ができないため、現在この機能は使用できません。" side_menu_labels: device: "デバイス" @@ -144,6 +153,7 @@ config_page: large: "高精度モデル ({{capacity}})" translation_compute_device: label: "AI翻訳の処理デバイス" + deepl_auth_key: label: "DeepL APIキーの登録" desc: "使用の際は、メイン画面にある {{translator}} をDeepL_APIに変更してください。\n※対応していない言語もあります。" @@ -152,6 +162,43 @@ config_page: edit: "編集" auth_key_success: "認証キーの更新が完了しました。" + plamo_auth_key: + label: "Plamo 認証キー" + select_plamo_model: + label: "Plamo モデルを選択" + + gemini_auth_key: + label: "Gemini 認証キー" + select_gemini_model: + label: "Gemini モデルを選択" + + openai_auth_key: + label: "OpenAI 認証キー" + select_openai_model: + label: "OpenAI モデルを選択" + + groq_auth_key: + label: "Groq 認証キー" + select_groq_model: + label: "Groq モデルを選択" + + openrouter_auth_key: + label: "OpenRouter 認証キー" + select_openrouter_model: + label: "OpenRouter モデルを選択" + + lmstudio_connection_check: + label: "LM Studio との接続確認" + select_lmstudio_model: + label: "LMStudio モデルを選択" + connection_required: "LMStudio との接続が必要" + + ollama_connection_check: + label: "Ollama との接続確認" + select_ollama_model: + label: "Ollama モデルを選択" + connection_required: "Ollama との接続が必要" + transcription: section_label_mic: "マイク" section_label_speaker: "スピーカー" diff --git a/locales/ko.yml b/locales/ko.yml index 6134f1ad..bc07d6c3 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -76,6 +76,13 @@ config_page: common: version: "버전 {{version}}" model_download_button_label: "다운로드" + correct_auth_key_required: + open_auth_key_webpage: + connection_check: + button_label: + checking: + connected: + disconnected: compute_device: desc: label_device: @@ -83,6 +90,8 @@ config_page: type_template_auto: type_template_low: type_template_high: + warning_labels: + unable_to_use_osc_query: side_menu_labels: device: "장치" @@ -144,6 +153,7 @@ config_page: large: "정밀 모델 ({{capacity}})" translation_compute_device: label: "AI 번역 처리 장치" + deepl_auth_key: label: "DeepL 인증키" desc: "사용시 메인화면에 있는 {{translator}}를 DeepL_API로 변경해 주세요.\n지원하지 않는 언어도 있습니다." @@ -152,6 +162,43 @@ config_page: edit: "편집" auth_key_success: "인증키 갱신이 완료되었습니다." + plamo_auth_key: + label: + select_plamo_model: + label: + + gemini_auth_key: + label: + select_gemini_model: + label: + + openai_auth_key: + label: + select_openai_model: + label: + + groq_auth_key: + label: + select_groq_model: + label: + + openrouter_auth_key: + label: + select_openrouter_model: + label: + + lmstudio_connection_check: + label: + select_lmstudio_model: + label: + connection_required: + + ollama_connection_check: + label: + select_ollama_model: + label: + connection_required: + transcription: section_label_mic: "마이크" section_label_speaker: "스피커" diff --git a/locales/readme_first.txt b/locales/readme_first.txt index 6d87d7d0..2d66cce4 100644 --- a/locales/readme_first.txt +++ b/locales/readme_first.txt @@ -10,4 +10,4 @@ Languages currently supported: - zh-Hant: 繁體中文 - zh-Hans: 简体中文 -Last updated: 2025/09 \ No newline at end of file +Last updated: 2025/12 \ No newline at end of file diff --git a/locales/zh-Hans.yml b/locales/zh-Hans.yml index 9bdb088c..c3403912 100644 --- a/locales/zh-Hans.yml +++ b/locales/zh-Hans.yml @@ -7,14 +7,14 @@ common: go_back_button_label: "返回" common_error: - no_device_mic: # 未检测到他人语音 ? - no_device_speaker: # 未检测到他人语音 ? - threshold_invalid_value: # 数值应为 {{min}} 至 {{max}} 之间。 ? 设定的数值从 {{min}} 到 {{max}} ? - failed_download_weight_ctranslate2: - failed_download_weight_whisper: - translation_limit: - deepl_auth_key_invalid_length: - deepl_auth_key_failed_authentication: "授权密匙错误或已达API使用上限" + no_device_mic: "未检测到麦克风设备。" + no_device_speaker: "未检测到扬声器设备。" + threshold_invalid_value: "可设置的数值范围为 {{min}} 到 {{max}}。" + failed_download_weight_ctranslate2: "CTranslate2 模型下载失败。" + failed_download_weight_whisper: "Whisper 模型下载失败。" + translation_limit: "已达到翻译引擎使用限制或临时受到使用限制。" + deepl_auth_key_invalid_length: "授权密钥字符数错误。" + deepl_auth_key_failed_authentication: "授权密钥错误或已达API使用上限。" invalid_value_mic_record_timeout: "数值应为 0 至 「{{mic_phrase_timeout_label}}」" invalid_value_mic_phrase_timeout: "转录间隔时间大于0秒且不能小于「{{mic_record_timeout_label}}」" @@ -25,7 +25,7 @@ common_error: invalid_value_speaker_max_phrase: "数值应为 0 以上" common_warning: - unable_to_use_osc_query: + unable_to_use_osc_query: "由于 OSC IP Address 的设置无法接收 OSC 数据,以下功能已自动禁用。" main_page: translation: "翻译" @@ -42,7 +42,7 @@ main_page: translator_label_default: "默认" translator_selector: - is_selected_same_language: + is_selected_same_language: "「{{your_language}}」和「{{target_language}}」选择了相同的语言,因此只能使用「{{ctranslate2}}」。" message_log: all: "全部" @@ -50,7 +50,7 @@ main_page: received: "接受" system: "系统" - resend_button_on_hover_desc: + resend_button_on_hover_desc: "长按发送" state_text_enabled: "启用" state_text_disabled: "停用" @@ -63,42 +63,51 @@ main_page: updating: "更新中..." update_modal: - cpu_desc: - cuda_desc: - cuda_compare_cpu_desc: - cuda_disk_space_desc: - close_modal: - download_latest_and_restart: - is_latest_version_already: - is_current_compute_device: + cpu_desc: "仅使用 CPU 作为处理设备" + cuda_desc: "可选择 CPU 和 NVIDIA GPU 作为处理设备" + cuda_compare_cpu_desc: "选择 GPU 时,与 CPU 相比处理更快" + cuda_disk_space_desc: "需要约 {{size}} 的磁盘空间" + close_modal: "关闭" + download_latest_and_restart: "将下载最新版本,\n应用程序将自动重启。" + is_latest_version_already: "已在使用最新版本" + is_current_compute_device: "当前使用的版本" config_page: common: version: "版本 {{version}}" - model_download_button_label: + model_download_button_label: "下载" + correct_auth_key_required: + open_auth_key_webpage: + connection_check: + button_label: + checking: + connected: + disconnected: compute_device: - desc: - label_device: - label_type: - type_template_auto: - type_template_low: - type_template_high: + desc: "各处理类型的精度和速度会因机器规格不同而有所差异,可能与显示顺序不同,请作为大致参考。" + label_device: "处理设备" + label_type: "处理类型" + type_template_auto: "自动" + type_template_low: "{{type_name}} (精度较低,处理较快)" + type_template_high: "{{type_name}} (精度较高,处理较慢)" + warning_labels: + unable_to_use_osc_query: "由于 OSC IP Address 的设置无法接收 OSC 数据,目前无法使用此功能。" side_menu_labels: - device: + device: "设备" appearance: "外观" translation: "翻译" - transcription: "转录" + transcription: "语音识别" others: "其他" - hotkeys: - plugins: + hotkeys: "快捷键" + plugins: "插件" advanced_settings: "高级设置" device: - check_volume: "Check Volume" - label_auto_select: "Auto Select" - label_host: "Host/Driver" - label_device: "Device" + check_volume: "音量检查" + label_auto_select: "自动选择" + label_host: "主机/驱动程序" + label_device: "设备" mic_host_device: label: "麦克风 (设备)" mic_dynamic_energy_threshold: @@ -129,8 +138,8 @@ config_page: show: "显示" show_and_disable_enter_key: "显示,并且停用‘回车发送信息’" show_resend_button: - label: - desc: + label: "显示重新发送按钮" + desc: "鼠标悬停在已发送消息日志上时,会显示重新发送按钮。点击进入编辑模式,长按重新发送。" font_family: label: "字体" ui_language: @@ -143,19 +152,57 @@ config_page: small: "普通模型 ({{capacity}})" large: "高精度模型 ({{capacity}})" translation_compute_device: - label: + label: "AI 翻译的处理设备" + deepl_auth_key: label: "DeepL 授权密匙" desc: "在使用的时候,使用时请在主屏幕上通过 DeepL_API 选择 {{translator}}\n※某些语言可能不支持" open_auth_key_webpage: "打开DeepL账号页面" - save: - edit: + save: "保存" + edit: "编辑" auth_key_success: "授权密匙认证完成。" + plamo_auth_key: + label: + select_plamo_model: + label: + + gemini_auth_key: + label: + select_gemini_model: + label: + + openai_auth_key: + label: + select_openai_model: + label: + + groq_auth_key: + label: + select_groq_model: + label: + + openrouter_auth_key: + label: + select_openrouter_model: + label: + + lmstudio_connection_check: + label: + select_lmstudio_model: + label: + connection_required: + + ollama_connection_check: + label: + select_ollama_model: + label: + connection_required: + transcription: - section_label_mic: "你的麦克风" - section_label_speaker: "他人声音" - section_label_transcription_engines: + section_label_mic: "麦克风" + section_label_speaker: "扬声器" + section_label_transcription_engines: "语音识别引擎" mic_record_timeout: label: "语音输入结束后的静音时间" desc: "当检测到静音并经过设定的秒数后,语音输入即被视为完成。" @@ -180,22 +227,22 @@ config_page: label: "语音接收时的最小单词数" desc: "转录字数的下限,只有超过这个数字,才会记录转录结果" select_transcription_engine: - label: + label: "语音识别使用的引擎" whisper_weight_type: - label: "选择某个Whisper模型" - desc: + label: "Whisper 模型类型" + desc: "容量越大的模型精度越高,但会占用更多 CPU/GPU 资源。\n※特别是比 medium 更大的模型,根据 CPU/GPU 性能可能难以使用。" model_template: "{{model_name}} 模型 ({{capacity}})" recommended_model_template: "{{model_name}} 模型 ({{capacity}}) (推荐)" transcription_compute_device: - label: + label: "AI 语音识别使用的处理设备" vr: - single_line: - multi_lines: - overlay_enable: + single_line: "单行" + multi_lines: "多行" + overlay_enable: "启用" restore_default_settings: "恢复默认设置" - position: - rotation: + position: "位置" + rotation: "旋转" x_position: "X轴(左右)" y_position: "Y轴(上下)" z_position: "Z轴(前后)" @@ -203,24 +250,24 @@ config_page: y_rotation: "Y轴旋转" z_rotation: "Z轴旋转" sample_text_button: - start: - stop: - sample_text: + start: "向 Overlay\n发送示例文本" + stop: "停止发送" + sample_text: "示例文本" opacity: "透明度" ui_scaling: "大小" display_duration: "显示持续时间" fadeout_duration: "渐隐持续时间" - common_settings: - tracker: - hmd: - left_hand: - right_hand: + common_settings: "通用设置" + tracker: "显示追踪器的位置" + hmd: "HMD" + left_hand: "左手" + right_hand: "右手" overlay_show_only_translated_messages: - label: + label: "仅显示翻译后的消息" others: - section_label_sounds: - section_label_message_formats: + section_label_sounds: "声音" + section_label_message_formats: "消息格式" auto_clear_the_message_box: label: "发言后自动清空chatbox" send_only_translated_messages: @@ -235,60 +282,60 @@ config_page: label: "发送信息至VRChat" desc: "不发送信息至VRChat的情况下也能使用它,但该功能现在并未完成.在想要发送信息时,请不要忘记打开这个功能." notification_vrc_sfx: - label: - desc: + label: "发送聊天时播放聊天框的通知音" + desc: "禁用此功能后,将不会播放其他人能听到的聊天框通知音,静默发送。" send_received_message_to_vrc: - label: - desc: + label: "将接收到的消息发送到 VRChat" + desc: "将从扬声器听到并转录的消息发送到 VRChat。" message_format_common: example_view: - title: - original_translated: - original_translated_multi: - translated_only_multi: - translated_only: - original_only: + title: "预览" + original_translated: "原文 + 翻译" + original_translated_multi: "原文 + 翻译(多语言)" + translated_only_multi: "仅翻译(多语言)" + translated_only: "仅翻译" + original_only: "仅原文" settings: - title: - original: - translated: - for_multi_translation: + title: "设置" + original: "原文" + translated: "翻译" + for_multi_translation: "多语言翻译用" send_message_format: - label: - desc: + label: "消息格式(发送)" + desc: "可以更改在 VRChat 中对方实际看到的格式。" received_message_format: - label: - desc: + label: "消息格式(Speaker2Chatbox)" + desc: "目前用于通过 Speaker2Chatbox 发送时的显示。" common_convert_message_hiragana_romaji: - desc_1: - desc_2: + desc_1: "仅在选择日语作为翻译语言时支持。" + desc_2: "由于日语的特殊性,精度有限。" convert_message_to_romaji: - label: - desc: + label: "显示罗马音" + desc: "与「{{convert_message_to_hiragana}}」同时启用时,将通过鼠标悬停显示。" convert_message_to_hiragana: - label: + label: "显示平假名" hotkeys: toggle_vrct_visibility: - label: + label: "切换 VRCT 最小化/激活" toggle_translation: - label: + label: "切换「{{translation}}」开/关" toggle_transcription_send: - label: + label: "切换「{{transcription_send}}」开/关" toggle_transcription_receive: - label: + label: "切换「{{transcription_receive}}」开/关" plugins: - downloaded_version: - latest_version: - available_after_updating: - unavailable_downloaded: - no_latest_info: - using_latest_version: - available_latest_version: - unavailable_latest_version: - available_in_latest_vrct_version: - unavailable_not_downloaded: + downloaded_version: "已下载版本:{{downloaded_version}}" + latest_version: "最新版本:{{latest_version}}" + available_after_updating: "更新到最新版后可用" + unavailable_downloaded: "当前不可用 与使用中的 VRCT 版本不兼容" + no_latest_info: "无法获取最新信息" + using_latest_version: "正在使用最新版本" + available_latest_version: "最新版本可用" + unavailable_latest_version: "最新版本当前不可用" + available_in_latest_vrct_version: "可在 VRCT 最新版中使用" + unavailable_not_downloaded: "当前不可用" advanced_settings: osc_ip_address: @@ -298,28 +345,28 @@ config_page: open_config_filepath: label: "打开设置文件" switch_compute_device: - label: + label: "切换 VRCT CPU/GPU 版本" enable_websocket: - label: + label: "启用 WebSocket 服务器" websocket_host: - label: + label: "WebSocket 主机" websocket_port: - label: + label: "WebSocket 端口" notifications: - save_success: + save_success: "设置已保存。" plugin_notifications: - downloading: - downloaded_success: - downloaded_error: + downloading: "正在下载插件。" + downloaded_success: "插件下载完成。" + downloaded_error: "插件下载失败。" - updating: - updated_success: - updated_error: + updating: "正在更新插件。" + updated_success: "插件更新完成。" + updated_error: "插件更新失败。" - disabled_out_of_support: - disabled_due_to_an_error: + disabled_out_of_support: "与当前版本不兼容。已禁用插件。" + disabled_due_to_an_error: "插件运行时检测到错误。请向插件开发者报告。" - is_enabled: - is_disabled: \ No newline at end of file + is_enabled: "插件已启用。" + is_disabled: "插件已禁用。" \ No newline at end of file diff --git a/locales/zh-Hant.yml b/locales/zh-Hant.yml index 827876b4..8362972b 100644 --- a/locales/zh-Hant.yml +++ b/locales/zh-Hant.yml @@ -7,14 +7,14 @@ common: go_back_button_label: "返回" common_error: - no_device_mic: + no_device_mic: "未偵測到麥克風裝置。" no_device_speaker: "未偵測到喇叭裝置。" threshold_invalid_value: "可以設置 {{min}} 到 {{max}} 之間的值。" - failed_download_weight_ctranslate2: - failed_download_weight_whisper: - translation_limit: - deepl_auth_key_invalid_length: - deepl_auth_key_failed_authentication: "授權金鑰錯誤或已達使用上限。" + failed_download_weight_ctranslate2: "CTranslate2 模型下載失敗。" + failed_download_weight_whisper: "Whisper 模型下載失敗。" + translation_limit: "已達到翻譯引擎使用限制或臨時受到使用限制。" + deepl_auth_key_invalid_length: "授權金鑰字元數錯誤。" + deepl_auth_key_failed_authentication: "授權金鑰錯誤或已達 API 使用上限。" invalid_value_mic_record_timeout: "不能大於「{{mic_phrase_timeout_label}}」,應為 0 或更高。" invalid_value_mic_phrase_timeout: "不能小於「{{mic_record_timeout_label}}」,應為 0 或更高。" @@ -25,7 +25,7 @@ common_error: invalid_value_speaker_max_phrase: "可以設置 0 或更高的數值。" common_warning: - unable_to_use_osc_query: + unable_to_use_osc_query: "由於 OSC IP Address 的設定無法接收 OSC 資料,以下功能已自動停用。" main_page: translation: "翻譯" @@ -42,7 +42,7 @@ main_page: translator_label_default: "預設" translator_selector: - is_selected_same_language: + is_selected_same_language: "「{{your_language}}」和「{{target_language}}」選擇了相同的語言,因此只能使用「{{ctranslate2}}」。" message_log: all: "全部" @@ -50,7 +50,7 @@ main_page: received: "已接收" system: "系統" - resend_button_on_hover_desc: + resend_button_on_hover_desc: "長按發送" state_text_enabled: "啟用" state_text_disabled: "停用" @@ -63,42 +63,51 @@ main_page: updating: "正在更新..." update_modal: - cpu_desc: - cuda_desc: - cuda_compare_cpu_desc: - cuda_disk_space_desc: - close_modal: - download_latest_and_restart: - is_latest_version_already: - is_current_compute_device: + cpu_desc: "僅使用 CPU 作為處理裝置" + cuda_desc: "可選擇 CPU 和 NVIDIA GPU 作為處理裝置" + cuda_compare_cpu_desc: "選擇 GPU 時,與 CPU 相比處理更快" + cuda_disk_space_desc: "需要約 {{size}} 的磁碟空間" + close_modal: "關閉" + download_latest_and_restart: "將下載最新版本,\n應用程式將自動重新啟動。" + is_latest_version_already: "已在使用最新版本" + is_current_compute_device: "目前使用的版本" config_page: common: version: "版本 {{version}}" - model_download_button_label: + model_download_button_label: "下載" + correct_auth_key_required: + open_auth_key_webpage: + connection_check: + button_label: + checking: + connected: + disconnected: compute_device: - desc: - label_device: - label_type: - type_template_auto: - type_template_low: - type_template_high: + desc: "各處理類型的精度和速度會因機器規格不同而有所差異,可能與顯示順序不同,請作為大致參考。" + label_device: "處理裝置" + label_type: "處理類型" + type_template_auto: "自動" + type_template_low: "{{type_name}}(精度較低,處理較快)" + type_template_high: "{{type_name}}(精度較高,處理較慢)" + warning_labels: + unable_to_use_osc_query: "由於 OSC IP Address 的設定無法接收 OSC 資料,目前無法使用此功能。" side_menu_labels: - device: + device: "裝置" appearance: "外觀" translation: "翻譯" - transcription: "轉錄" + transcription: "語音辨識" others: "其他" - hotkeys: - plugins: + hotkeys: "快捷鍵" + plugins: "外掛程式" advanced_settings: "進階設定" device: - check_volume: "Check Volume" - label_auto_select: "Auto Select" - label_host: "Host/Driver" - label_device: "Device" + check_volume: "音量檢查" + label_auto_select: "自動選擇" + label_host: "主機/驅動程式" + label_device: "裝置" mic_host_device: label: "麥克風裝置" mic_dynamic_energy_threshold: @@ -129,8 +138,8 @@ config_page: show: "顯示" show_and_disable_enter_key: "顯示並停用 Enter 鍵發送" show_resend_button: - label: - desc: + label: "顯示重新發送按鈕" + desc: "滑鼠懸停在已發送訊息記錄上時,會顯示重新發送按鈕。點擊進入編輯模式,長按重新發送。" font_family: label: "字型" ui_language: @@ -143,19 +152,57 @@ config_page: small: "基本模型({{capacity}})" large: "高準確率模型({{capacity}})" translation_compute_device: - label: + label: "AI 翻譯的處理裝置" + deepl_auth_key: label: "DeepL 授權金鑰" desc: "使用 DeepL API 時請在主螢幕選擇 {{translator}}。※可能不支援某些語言。" open_auth_key_webpage: "打開 DeepL 帳號頁面" - save: - edit: + save: "儲存" + edit: "編輯" auth_key_success: "授權金鑰更新完成。" + plamo_auth_key: + label: + select_plamo_model: + label: + + gemini_auth_key: + label: + select_gemini_model: + label: + + openai_auth_key: + label: + select_openai_model: + label: + + groq_auth_key: + label: + select_groq_model: + label: + + openrouter_auth_key: + label: + select_openrouter_model: + label: + + lmstudio_connection_check: + label: + select_lmstudio_model: + label: + connection_required: + + ollama_connection_check: + label: + select_ollama_model: + label: + connection_required: + transcription: section_label_mic: "麥克風" section_label_speaker: "喇叭" - section_label_transcription_engines: + section_label_transcription_engines: "語音辨識引擎" mic_record_timeout: label: "麥克風音訊 - 判定結束時間" desc: "麥克風未收到音訊後,結束一段話的判定時間(秒)。" @@ -180,22 +227,22 @@ config_page: label: "喇叭音訊 - 最大單詞數量" desc: "只有在單詞超過此數量時,才會記錄結果並發送到 VRChat。" select_transcription_engine: - label: + label: "語音辨識使用的引擎" whisper_weight_type: - label: "選擇 Whisper 模型" - desc: + label: "Whisper 模型類型" + desc: "容量越大的模型精度越高,但會占用更多 CPU/GPU 資源。\n※特別是比 medium 更大的模型,根據 CPU/GPU 效能可能難以使用。" model_template: "{{model_name}}模型({{capacity}})" recommended_model_template: "{{model_name}}模型({{capacity}})(推薦)" transcription_compute_device: - label: + label: "AI 語音辨識使用的處理裝置" vr: - single_line: - multi_lines: - overlay_enable: + single_line: "單行" + multi_lines: "多行" + overlay_enable: "啟用" restore_default_settings: "恢復預設設定" - position: - rotation: + position: "位置" + rotation: "旋轉" x_position: "X軸(左右)" y_position: "Y軸(上下)" z_position: "Z軸(前後)" @@ -203,24 +250,24 @@ config_page: y_rotation: "Y軸旋轉" z_rotation: "Z軸旋轉" sample_text_button: - start: - stop: - sample_text: + start: "向 Overlay\n發送範例文字" + stop: "停止發送" + sample_text: "範例文字" opacity: "透明度" ui_scaling: "介面縮放" display_duration: "顯示持續時間" fadeout_duration: "淡出持續時間" - common_settings: - tracker: - hmd: - left_hand: - right_hand: + common_settings: "通用設定" + tracker: "顯示追蹤器的位置" + hmd: "HMD" + left_hand: "左手" + right_hand: "右手" overlay_show_only_translated_messages: - label: + label: "僅顯示翻譯後的訊息" others: - section_label_sounds: - section_label_message_formats: + section_label_sounds: "音效" + section_label_message_formats: "訊息格式" auto_clear_the_message_box: label: "自動清除 Chatbox" send_only_translated_messages: @@ -235,60 +282,60 @@ config_page: label: "發送訊息到 VRChat" desc: "當你打算向 VRChat 發送訊息時啟用此功能。" notification_vrc_sfx: - label: - desc: + label: "發送聊天時播放聊天框的通知音" + desc: "停用此功能後,將不會播放其他人能聽到的聊天框通知音,靜默發送。" send_received_message_to_vrc: - label: - desc: + label: "將接收到的訊息發送到 VRChat" + desc: "將從喇叭聽到並轉錄的訊息發送到 VRChat。" message_format_common: example_view: - title: - original_translated: - original_translated_multi: - translated_only_multi: - translated_only: - original_only: + title: "預覽" + original_translated: "原文 + 翻譯" + original_translated_multi: "原文 + 翻譯(多語言)" + translated_only_multi: "僅翻譯(多語言)" + translated_only: "僅翻譯" + original_only: "僅原文" settings: - title: - original: - translated: - for_multi_translation: + title: "設定" + original: "原文" + translated: "翻譯" + for_multi_translation: "多語言翻譯用" send_message_format: - label: - desc: + label: "訊息格式(發送)" + desc: "可以變更在 VRChat 中對方實際看到的格式。" received_message_format: - label: - desc: + label: "訊息格式(Speaker2Chatbox)" + desc: "目前用於透過 Speaker2Chatbox 發送時的顯示。" common_convert_message_hiragana_romaji: - desc_1: - desc_2: + desc_1: "僅在選擇日語作為翻譯語言時支援。" + desc_2: "由於日語的特殊性,精度有限。" convert_message_to_romaji: - label: - desc: + label: "顯示羅馬字" + desc: "與「{{convert_message_to_hiragana}}」同時啟用時,將透過滑鼠懸停顯示。" convert_message_to_hiragana: - label: + label: "顯示平假名" hotkeys: toggle_vrct_visibility: - label: + label: "切換 VRCT 最小化/啟動" toggle_translation: - label: + label: "切換「{{translation}}」開/關" toggle_transcription_send: - label: + label: "切換「{{transcription_send}}」開/關" toggle_transcription_receive: - label: + label: "切換「{{transcription_receive}}」開/關" plugins: - downloaded_version: - latest_version: - available_after_updating: - unavailable_downloaded: - no_latest_info: - using_latest_version: - available_latest_version: - unavailable_latest_version: - available_in_latest_vrct_version: - unavailable_not_downloaded: + downloaded_version: "已下載版本:{{downloaded_version}}" + latest_version: "最新版本:{{latest_version}}" + available_after_updating: "更新到最新版後可用" + unavailable_downloaded: "目前不可用 與使用中的 VRCT 版本不相容" + no_latest_info: "無法取得最新資訊" + using_latest_version: "正在使用最新版本" + available_latest_version: "最新版本可用" + unavailable_latest_version: "最新版本目前不可用" + available_in_latest_vrct_version: "可在 VRCT 最新版中使用" + unavailable_not_downloaded: "目前不可用" advanced_settings: osc_ip_address: @@ -298,28 +345,28 @@ config_page: open_config_filepath: label: "打開設定文件" switch_compute_device: - label: + label: "切換 VRCT CPU/GPU 版本" enable_websocket: - label: + label: "啟用 WebSocket 伺服器" websocket_host: - label: + label: "WebSocket 主機" websocket_port: - label: + label: "WebSocket 連接埠" notifications: - save_success: + save_success: "設定已儲存。" plugin_notifications: - downloading: - downloaded_success: - downloaded_error: + downloading: "正在下載外掛程式。" + downloaded_success: "外掛程式下載完成。" + downloaded_error: "外掛程式下載失敗。" - updating: - updated_success: - updated_error: + updating: "正在更新外掛程式。" + updated_success: "外掛程式更新完成。" + updated_error: "外掛程式更新失敗。" - disabled_out_of_support: - disabled_due_to_an_error: + disabled_out_of_support: "與目前版本不相容。已停用外掛程式。" + disabled_due_to_an_error: "外掛程式執行時偵測到錯誤。請向外掛程式開發者回報。" - is_enabled: - is_disabled: \ No newline at end of file + is_enabled: "外掛程式已啟用。" + is_disabled: "外掛程式已停用。" \ No newline at end of file diff --git a/package.json b/package.json index 184d5c9a..69bb278d 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,28 @@ { "name": "vrct", "private": true, - "version": "0.0.0", + "version": "3.3.2", "type": "module", "scripts": { - "setup-python": "install.bat", - "build-python": "build.bat", - "build-python-cuda": "build_cuda.bat", + "setup-python": "bat\\install.bat", + "build-python": "bat\\build.bat", + "build-python-cuda": "bat\\build_cuda.bat", "vite": "vite", "vite-build": "vite build", "vite-preview": "vite preview", "tauri": "tauri", "tauri-dev": "tauri dev", - "task-kill": "python task_kill.py", - "clean": "python clean.py", - "dev": "npm run task-kill && npm run build-python && npm run dev-ui", - "dev-cuda": "npm run task-kill && npm run build-python-cuda && npm run dev-ui", - "dev-ui": "npm-run-all --parallel vite tauri-dev", - "build": "npm run clean && npm run build-python && npm run vite-build && npm run tauri build", - "build-cuda": "npm run clean && npm run build-python-cuda && npm run vite-build && npm run tauri build", - "release": "npm run build && python zip.py --zip_name VRCT.zip", - "release-cuda": "npm run build-cuda && python zip.py --zip_name VRCT_cuda.zip", - "release-all": "npm run release && npm run release-cuda" + "task-kill": "python utils\\task_kill.py", + "clean": "python utils\\clean.py", + "update-version": "python utils\\update_version.py", + "dev": "npm run task-kill && npm run clean && npm run update-version && npm run build-python && npm run dev-ui", + "dev-cuda": "npm run task-kill && npm run clean && npm run update-version && npm run build-python-cuda && npm run dev-ui", + "dev-ui": "npm run task-kill && npm-run-all --parallel vite tauri-dev", + "build": "npm run task-kill && npm run clean && npm run update-version && npm run build-python && npm run vite-build && npm run tauri build", + "build-cuda": "npm run task-kill && npm run clean && npm run update-version && npm run build-python-cuda && npm run vite-build && npm run tauri build", + "release": "npm run update-version && npm run build && python utils\\zip.py --zip_name VRCT.zip", + "release-cuda": "npm run update-version && npm run build-cuda && python utils\\zip.py --zip_name VRCT_cuda.zip", + "release-all": "npm run update-version && npm run release && npm run release-cuda" }, "dependencies": { "@babel/standalone": "7.27.0", diff --git a/backend_cuda.spec b/spec/backend.spec similarity index 58% rename from backend_cuda.spec rename to spec/backend.spec index 8d80b6ec..786d7233 100644 --- a/backend_cuda.spec +++ b/spec/backend.spec @@ -2,17 +2,17 @@ a = Analysis( - ['src-python\\mainloop.py'], + ['..\\src-python\\mainloop.py'], pathex=[], binaries=[], datas=[ - ('./src-python/models/overlay/fonts', 'fonts/'), - ('./src-python/models/translation/prompt', 'prompt/'), - ('./src-python/models/translation/languages', 'languages/'), - ('.venv_cuda/Lib/site-packages/zeroconf', 'zeroconf/'), - ('.venv_cuda/Lib/site-packages/openvr', 'openvr/'), - ('.venv_cuda/Lib/site-packages/faster_whisper', 'faster_whisper/'), - ('.venv/Lib/site-packages/hf_xet', 'hf_xet/') + ('./../src-python/models/overlay/fonts', 'fonts/'), + ('./../src-python/models/translation/translation_settings/prompt', 'translation_settings/prompt/'), + ('./../src-python/models/translation/translation_settings/languages', 'translation_settings/languages/'), + ('./../.venv/Lib/site-packages/zeroconf', 'zeroconf/'), + ('./../.venv/Lib/site-packages/openvr', 'openvr/'), + ('./../.venv/Lib/site-packages/faster_whisper', 'faster_whisper/'), + ('./../.venv/Lib/site-packages/hf_xet', 'hf_xet/') ], hiddenimports=[], hookspath=[], diff --git a/backend.spec b/spec/backend_cuda.spec similarity index 57% rename from backend.spec rename to spec/backend_cuda.spec index 605c82fb..e32776ed 100644 --- a/backend.spec +++ b/spec/backend_cuda.spec @@ -2,17 +2,17 @@ a = Analysis( - ['src-python\\mainloop.py'], + ['..\\src-python\\mainloop.py'], pathex=[], binaries=[], datas=[ - ('./src-python/models/overlay/fonts', 'fonts/'), - ('./src-python/models/translation/prompt', 'prompt/'), - ('./src-python/models/translation/languages', 'languages/'), - ('.venv/Lib/site-packages/zeroconf', 'zeroconf/'), - ('.venv/Lib/site-packages/openvr', 'openvr/'), - ('.venv/Lib/site-packages/faster_whisper', 'faster_whisper/'), - ('.venv/Lib/site-packages/hf_xet', 'hf_xet/') + ('./../src-python/models/overlay/fonts', 'fonts/'), + ('./../src-python/models/translation/translation_settings/prompt', 'translation_settings/prompt/'), + ('./../src-python/models/translation/translation_settings/languages', 'translation_settings/languages/'), + ('./../.venv_cuda/Lib/site-packages/zeroconf', 'zeroconf/'), + ('./../.venv_cuda/Lib/site-packages/openvr', 'openvr/'), + ('./../.venv_cuda/Lib/site-packages/faster_whisper', 'faster_whisper/'), + ('./../.venv/Lib/site-packages/hf_xet', 'hf_xet/') ], hiddenimports=[], hookspath=[], diff --git a/src-python/config.py b/src-python/config.py index d5e600ac..e7420e36 100644 --- a/src-python/config.py +++ b/src-python/config.py @@ -272,8 +272,8 @@ class ManagedProperty: if self.readonly: raise AttributeError(f"Cannot set read-only property '{self.name}'") - # Type check if requested - if self.type_ is not None and not isinstance(value, self.type_): + # Type check if requested(Noneは常に許可) + if self.type_ is not None and value is not None and not isinstance(value, self.type_): return # Allowed-values check: can be an iterable or a callable @@ -527,6 +527,19 @@ def _compute_device_validator(val, inst): return copy.deepcopy(val) return None +def _allowed_in_populated(list_attr_name: str): + def _inner(value, inst): + try: + lst = getattr(inst, list_attr_name) + except Exception: + return True # インスタンス状態取得失敗時も弾かない + if not lst: # 空/未初期化 + return True + if value is None: + return True + return value in lst + return _inner + class Config: """Application configuration singleton. @@ -558,8 +571,16 @@ class Config: return cls._instance def saveConfigToFile(self) -> None: + # 永続化対象を descriptor 情報 (json_serializable_vars) から再構成 + filtered = {} + for var_name, var_func in json_serializable_vars.items(): + try: + filtered[var_name] = var_func(self) + except Exception: + pass + self._config_data = filtered with open(self.PATH_CONFIG, "w", encoding="utf-8") as fp: - json_dump(self._config_data, fp, indent=4, ensure_ascii=False) + json_dump(filtered, fp, indent=4, ensure_ascii=False) def saveConfig(self, key: str, value: Any, immediate_save: bool = False) -> None: self._config_data[key] = value @@ -581,9 +602,6 @@ class Config: PATH_LOGS = ManagedProperty('PATH_LOGS', readonly=True, serialize=False) GITHUB_URL = ManagedProperty('GITHUB_URL', readonly=True, serialize=False) UPDATER_URL = ManagedProperty('UPDATER_URL', readonly=True, serialize=False) - BOOTH_URL = ManagedProperty('BOOTH_URL', readonly=True, serialize=False) - DOCUMENTS_URL = ManagedProperty('DOCUMENTS_URL', readonly=True, serialize=False) - DEEPL_AUTH_KEY_PAGE_URL = ManagedProperty('DEEPL_AUTH_KEY_PAGE_URL', readonly=True, serialize=False) MAX_MIC_THRESHOLD = ManagedProperty('MAX_MIC_THRESHOLD', readonly=True, serialize=False) MAX_SPEAKER_THRESHOLD = ManagedProperty('MAX_SPEAKER_THRESHOLD', readonly=True, serialize=False) WATCHDOG_TIMEOUT = ManagedProperty('WATCHDOG_TIMEOUT', readonly=True, serialize=False) @@ -601,12 +619,12 @@ class Config: # Read Write # --- Simple boolean flags (managed by descriptor) --- - ENABLE_TRANSLATION = ManagedProperty('ENABLE_TRANSLATION', type_=bool) - ENABLE_TRANSCRIPTION_SEND = ManagedProperty('ENABLE_TRANSCRIPTION_SEND', type_=bool) - ENABLE_TRANSCRIPTION_RECEIVE = ManagedProperty('ENABLE_TRANSCRIPTION_RECEIVE', type_=bool) - ENABLE_FOREGROUND = ManagedProperty('ENABLE_FOREGROUND', type_=bool) - ENABLE_CHECK_ENERGY_SEND = ManagedProperty('ENABLE_CHECK_ENERGY_SEND', type_=bool) - ENABLE_CHECK_ENERGY_RECEIVE = ManagedProperty('ENABLE_CHECK_ENERGY_RECEIVE', type_=bool) + ENABLE_TRANSLATION = ManagedProperty('ENABLE_TRANSLATION', type_=bool, serialize=False) + ENABLE_TRANSCRIPTION_SEND = ManagedProperty('ENABLE_TRANSCRIPTION_SEND', type_=bool, serialize=False) + ENABLE_TRANSCRIPTION_RECEIVE = ManagedProperty('ENABLE_TRANSCRIPTION_RECEIVE', type_=bool, serialize=False) + ENABLE_FOREGROUND = ManagedProperty('ENABLE_FOREGROUND', type_=bool, serialize=False) + ENABLE_CHECK_ENERGY_SEND = ManagedProperty('ENABLE_CHECK_ENERGY_SEND', type_=bool, serialize=False) + ENABLE_CHECK_ENERGY_RECEIVE = ManagedProperty('ENABLE_CHECK_ENERGY_RECEIVE', type_=bool, serialize=False) # --- Selectable dict/list properties (managed by descriptor, not serialized) --- # These are dynamically generated in init_config() based on installed packages/APIs @@ -617,6 +635,8 @@ class Config: SELECTABLE_PLAMO_MODEL_LIST = ManagedProperty('SELECTABLE_PLAMO_MODEL_LIST', type_=list, serialize=False, mutable_tracking=True) SELECTABLE_GEMINI_MODEL_LIST = ManagedProperty('SELECTABLE_GEMINI_MODEL_LIST', type_=list, serialize=False, mutable_tracking=True) SELECTABLE_OPENAI_MODEL_LIST = ManagedProperty('SELECTABLE_OPENAI_MODEL_LIST', type_=list, serialize=False, mutable_tracking=True) + SELECTABLE_GROQ_MODEL_LIST = ManagedProperty('SELECTABLE_GROQ_MODEL_LIST', type_=list, serialize=False, mutable_tracking=True) + SELECTABLE_OPENROUTER_MODEL_LIST = ManagedProperty('SELECTABLE_OPENROUTER_MODEL_LIST', type_=list, serialize=False, mutable_tracking=True) SELECTABLE_LMSTUDIO_MODEL_LIST = ManagedProperty('SELECTABLE_LMSTUDIO_MODEL_LIST', type_=list, serialize=False, mutable_tracking=True) SELECTABLE_OLLAMA_MODEL_LIST = ManagedProperty('SELECTABLE_OLLAMA_MODEL_LIST', type_=list, serialize=False, mutable_tracking=True) @@ -711,11 +731,13 @@ class Config: USE_EXCLUDE_WORDS = ManagedProperty('USE_EXCLUDE_WORDS', type_=bool) CTRANSLATE2_WEIGHT_TYPE = ManagedProperty('CTRANSLATE2_WEIGHT_TYPE', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_LIST) WHISPER_WEIGHT_TYPE = ManagedProperty('WHISPER_WEIGHT_TYPE', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_WHISPER_WEIGHT_TYPE_LIST) - SELECTED_PLAMO_MODEL = ManagedProperty('SELECTED_PLAMO_MODEL', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_PLAMO_MODEL_LIST) - SELECTED_GEMINI_MODEL = ManagedProperty('SELECTED_GEMINI_MODEL', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_GEMINI_MODEL_LIST) - SELECTED_OPENAI_MODEL = ManagedProperty('SELECTED_OPENAI_MODEL', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_OPENAI_MODEL_LIST) - SELECTED_LMSTUDIO_MODEL = ManagedProperty('SELECTED_LMSTUDIO_MODEL', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_LMSTUDIO_MODEL_LIST) - SELECTED_OLLAMA_MODEL = ManagedProperty('SELECTED_OLLAMA_MODEL', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_OLLAMA_MODEL_LIST) + SELECTED_PLAMO_MODEL = ManagedProperty('SELECTED_PLAMO_MODEL', type_=str, allowed=_allowed_in_populated('SELECTABLE_PLAMO_MODEL_LIST')) + SELECTED_GEMINI_MODEL = ManagedProperty('SELECTED_GEMINI_MODEL', type_=str, allowed=_allowed_in_populated('SELECTABLE_GEMINI_MODEL_LIST')) + SELECTED_OPENAI_MODEL = ManagedProperty('SELECTED_OPENAI_MODEL', type_=str, allowed=_allowed_in_populated('SELECTABLE_OPENAI_MODEL_LIST')) + SELECTED_GROQ_MODEL = ManagedProperty('SELECTED_GROQ_MODEL', type_=str, allowed=_allowed_in_populated('SELECTABLE_GROQ_MODEL_LIST')) + SELECTED_OPENROUTER_MODEL = ManagedProperty('SELECTED_OPENROUTER_MODEL', type_=str, allowed=_allowed_in_populated('SELECTABLE_OPENROUTER_MODEL_LIST')) + SELECTED_LMSTUDIO_MODEL = ManagedProperty('SELECTED_LMSTUDIO_MODEL', type_=str, allowed=_allowed_in_populated('SELECTABLE_LMSTUDIO_MODEL_LIST')) + SELECTED_OLLAMA_MODEL = ManagedProperty('SELECTED_OLLAMA_MODEL', type_=str, allowed=_allowed_in_populated('SELECTABLE_OLLAMA_MODEL_LIST')) # --- Translation and language settings --- MIC_WORD_FILTER = ValidatedProperty('MIC_WORD_FILTER', _mic_word_filter_validator) @@ -740,7 +762,7 @@ class Config: def init_config(self): # Read Only - self._VERSION = "3.3.1" + self._VERSION = "3.3.2" if getattr(sys, 'frozen', False): self._PATH_LOCAL = os_path.dirname(sys.executable) else: @@ -750,9 +772,6 @@ class Config: os_makedirs(self._PATH_LOGS, exist_ok=True) self._GITHUB_URL = "https://api.github.com/repos/misyaguziya/VRCT/releases/latest" self._UPDATER_URL = "https://api.github.com/repos/misyaguziya/VRCT_updater/releases/latest" - self._BOOTH_URL = "https://misyaguziya.booth.pm/" - self._DOCUMENTS_URL = "https://mzsoftware.notion.site/VRCT-Documents-be79b7a165f64442ad8f326d86c22246" - self._DEEPL_AUTH_KEY_PAGE_URL = "https://www.deepl.com/ja/account/summary" self._MAX_MIC_THRESHOLD = 2000 self._MAX_SPEAKER_THRESHOLD = 4000 @@ -798,6 +817,8 @@ class Config: self._SELECTABLE_PLAMO_MODEL_LIST = [] self._SELECTABLE_GEMINI_MODEL_LIST = [] self._SELECTABLE_OPENAI_MODEL_LIST = [] + self._SELECTABLE_GROQ_MODEL_LIST = [] + self._SELECTABLE_OPENROUTER_MODEL_LIST = [] self._SELECTABLE_LMSTUDIO_MODEL_LIST = [] self._SELECTABLE_OLLAMA_MODEL_LIST = [] @@ -922,6 +943,8 @@ class Config: "Plamo_API": None, "Gemini_API": None, "OpenAI_API": None, + "Groq_API": None, + "OpenRouter_API": None, } self._USE_EXCLUDE_WORDS = True self._SELECTED_TRANSLATION_COMPUTE_DEVICE = copy.deepcopy(self.SELECTABLE_COMPUTE_DEVICE_LIST[0]) @@ -930,6 +953,8 @@ class Config: self._SELECTED_PLAMO_MODEL = None self._SELECTED_GEMINI_MODEL = None self._SELECTED_OPENAI_MODEL = None + self._SELECTED_GROQ_MODEL = None + self._SELECTED_OPENROUTER_MODEL = None self._LMSTUDIO_URL = "http://127.0.0.1:1234/v1" self._SELECTED_LMSTUDIO_MODEL = None self._SELECTED_OLLAMA_MODEL = None @@ -1012,15 +1037,46 @@ class Config: self._config_data = json_load(fp) for key, value in self._config_data.items(): + # 読み込み時: serialize=True かつ readonlyでない Descriptor のみ反映。 + # 未知キー(Descriptorなし)は無視して注入を防止。 try: - setattr(self, key, value) + descriptor = getattr(type(self), key, None) + if isinstance(descriptor, ManagedProperty): + if descriptor.readonly or not descriptor.serialize: + continue + setattr(self, key, value) + elif isinstance(descriptor, ValidatedProperty): + if not descriptor.serialize: + continue + setattr(self, key, value) + else: + # 不明キーは破棄(古い/不要/改竄の可能性) + continue except Exception: errorLogging() + self.saveConfigToFile() - with open(self.PATH_CONFIG, 'w', encoding="utf-8") as fp: - for var_name, var_func in json_serializable_vars.items(): - self._config_data[var_name] = var_func(self) - json_dump(self._config_data, fp, indent=4, ensure_ascii=False) + def revalidate_selected_models(self): + pairs = [ + ('SELECTED_PLAMO_MODEL', 'SELECTABLE_PLAMO_MODEL_LIST'), + ('SELECTED_GEMINI_MODEL', 'SELECTABLE_GEMINI_MODEL_LIST'), + ('SELECTED_OPENAI_MODEL', 'SELECTABLE_OPENAI_MODEL_LIST'), + ('SELECTED_GROQ_MODEL', 'SELECTABLE_GROQ_MODEL_LIST'), + ('SELECTED_OPENROUTER_MODEL', 'SELECTABLE_OPENROUTER_MODEL_LIST'), + ('SELECTED_LMSTUDIO_MODEL', 'SELECTABLE_LMSTUDIO_MODEL_LIST'), + ('SELECTED_OLLAMA_MODEL', 'SELECTABLE_OLLAMA_MODEL_LIST'), + ] + for sel_attr, list_attr in pairs: + try: + current = getattr(self, sel_attr) + lst = getattr(self, list_attr) + if lst and current is not None and current not in lst: + if len(lst) > 0: + setattr(self, sel_attr, lst[0]) + else: + setattr(self, sel_attr, None) + except Exception: + errorLogging() # Auto-register all descriptors after Config class definition _auto_register_descriptors() diff --git a/src-python/controller.py b/src-python/controller.py index 360296ec..535653b6 100644 --- a/src-python/controller.py +++ b/src-python/controller.py @@ -2,11 +2,13 @@ from typing import Callable, Any, List, Optional from time import sleep from subprocess import Popen from threading import Thread +from concurrent.futures import ThreadPoolExecutor, as_completed import re from device_manager import device_manager from config import config from model import model from utils import removeLog, printLog, errorLogging, isConnectedNetwork, isValidIpAddress, isAvailableWebSocketServer +from errors import ErrorCode, VRCTError class Controller: def __init__(self) -> None: @@ -153,13 +155,14 @@ class Controller: def progressBarMicEnergy(self, energy) -> None: if energy is False: + error_response = VRCTError.create_error_response( + ErrorCode.DEVICE_NO_MIC, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_device"], - { - "message":"No mic device detected", - "data": None - }, + error_response["result"], ) else: self.run( @@ -170,13 +173,14 @@ class Controller: def progressBarSpeakerEnergy(self, energy) -> None: if energy is False: + error_response = VRCTError.create_error_response( + ErrorCode.DEVICE_NO_SPEAKER, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_device"], - { - "message":"No speaker device detected", - "data": None - }, + error_response["result"], ) else: self.run( @@ -209,13 +213,14 @@ class Controller: self.weight_type, ) else: + error_response = VRCTError.create_error_response( + ErrorCode.WEIGHT_CTRANSLATE2_DOWNLOAD, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_ctranslate2_weight"], - { - "message":"CTranslate2 weight download error", - "data": None - }, + error_response["result"], ) class DownloadWhisper: @@ -242,13 +247,14 @@ class Controller: self.weight_type, ) else: + error_response = VRCTError.create_error_response( + ErrorCode.WEIGHT_WHISPER_DOWNLOAD, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_whisper_weight"], - { - "message":"Whisper weight download error", - "data": None - }, + error_response["result"], ) def micMessage(self, result: dict) -> None: @@ -292,27 +298,31 @@ class Controller: "data": None }, ) + else: + pass except Exception as e: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_VRAM_MIC, + data=error_message + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_mic_vram_overflow"], - { - "message":"VRAM out of memory during translation of mic", - "data": error_message - }, + error_response["result"], ) # 翻訳機能をOFFにする self.setDisableTranslation() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_translation"], - { - "message":"Translation disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) return else: @@ -418,6 +428,8 @@ class Controller: translation_text = f" ({'/'.join(translation)})" if translation else "" model.logger.info(f"[SENT] {message}{translation_text}") + model.addTranslationHistory("mic", message) + def speakerMessage(self, result:dict) -> None: message = result["text"] language = result["language"] @@ -450,35 +462,40 @@ class Controller: translation, success = model.getOutputTranslate(message, source_language=language) if all(success) is not True: self.changeToCTranslate2Process() - self.run( - 400, - self.run_mapping["error_translation_engine"], - { - "message":"Translation engine limit error", - "data": None - }, + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_ENGINE_LIMIT, + data=None ) + self.run( + error_response["status"], + self.run_mapping["error_translation_engine"], + error_response["result"], + ) + else: + pass except Exception as e: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_VRAM_SPEAKER, + data=error_message + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_speaker_vram_overflow"], - { - "message":"VRAM out of memory during translation of speaker", - "data": error_message - }, + error_response["result"], ) # 翻訳機能をOFFにする self.setDisableTranslation() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_translation"], - { - "message":"Translation disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) return else: @@ -600,6 +617,8 @@ class Controller: translation_text = f" ({'/'.join(translation)})" if translation else "" model.logger.info(f"[RECEIVED] {message}{translation_text}") + model.addTranslationHistory("speaker", message) + def chatMessage(self, data) -> dict: id = data["id"] message = data["message"] @@ -623,35 +642,40 @@ class Controller: if all(success) is not True: self.changeToCTranslate2Process() - self.run( - 400, - self.run_mapping["error_translation_engine"], - { - "message":"Translation engine limit error", - "data": None - }, + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_ENGINE_LIMIT, + data=None ) + self.run( + error_response["status"], + self.run_mapping["error_translation_engine"], + error_response["result"], + ) + else: + pass except Exception as e: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_VRAM_CHAT, + data=error_message + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_chat_vram_overflow"], - { - "message":"VRAM out of memory during translation of chat", - "data": error_message - }, + error_response["result"], ) # 翻訳機能をOFFにする self.setDisableTranslation() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_translation"], - { - "message":"Translation disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) # エラー時は翻訳なしで返す return {"status":200, @@ -756,6 +780,8 @@ class Controller: translation_text = f" ({'/'.join(translation)})" if translation else "" model.logger.info(f"[CHAT] {message}{translation_text}") + model.addTranslationHistory("chat", message) + return { "status":200, "result":{ @@ -845,22 +871,24 @@ class Controller: if is_vram_error: # Defaultのデバイス設定に戻す printLog("VRAM error detected, reverting device setting") + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_VRAM_ENABLE, + data=error_message + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_enable_vram_overflow"], - { - "message":"VRAM out of memory enabling translation", - "data": error_message - }, + error_response["result"], ) self.setDisableTranslation() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_translation"], - { - "message":"Translation disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) model.changeTranslatorCTranslate2Model() model.setChangedTranslatorParameters(False) @@ -1185,13 +1213,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Mic energy threshold value is out of range", - "data": config.MIC_THRESHOLD - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_MIC_THRESHOLD, + data=config.MIC_THRESHOLD + ) else: response = {"status":status, "result":config.MIC_THRESHOLD} return response @@ -1226,13 +1251,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Mic record timeout value is out of range", - "data": config.MIC_RECORD_TIMEOUT - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_MIC_RECORD_TIMEOUT, + data=config.MIC_RECORD_TIMEOUT + ) else: response = {"status":200, "result":config.MIC_RECORD_TIMEOUT} return response @@ -1250,13 +1272,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Mic phrase timeout value is out of range", - "data": config.MIC_PHRASE_TIMEOUT - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_MIC_PHRASE_TIMEOUT, + data=config.MIC_PHRASE_TIMEOUT + ) else: response = {"status":200, "result":config.MIC_PHRASE_TIMEOUT} return response @@ -1274,13 +1293,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Mic max phrases value is out of range", - "data": config.MIC_MAX_PHRASES - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_MIC_MAX_PHRASES, + data=config.MIC_MAX_PHRASES + ) else: response = {"status":200, "result":config.MIC_MAX_PHRASES} return response @@ -1368,13 +1384,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Speaker energy threshold value is out of range", - "data": config.SPEAKER_THRESHOLD - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_SPEAKER_THRESHOLD, + data=config.SPEAKER_THRESHOLD + ) else: response = {"status":200, "result":config.SPEAKER_THRESHOLD} return response @@ -1408,13 +1421,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Speaker record timeout value is out of range", - "data": config.SPEAKER_RECORD_TIMEOUT - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_SPEAKER_RECORD_TIMEOUT, + data=config.SPEAKER_RECORD_TIMEOUT + ) else: response = {"status":200, "result":config.SPEAKER_RECORD_TIMEOUT} return response @@ -1432,13 +1442,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Speaker phrase timeout value is out of range", - "data": config.SPEAKER_PHRASE_TIMEOUT - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_SPEAKER_PHRASE_TIMEOUT, + data=config.SPEAKER_PHRASE_TIMEOUT + ) else: response = {"status":200, "result":config.SPEAKER_PHRASE_TIMEOUT} return response @@ -1457,13 +1464,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Speaker max phrases value is out of range", - "data": config.SPEAKER_MAX_PHRASES - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_SPEAKER_MAX_PHRASES, + data=config.SPEAKER_MAX_PHRASES + ) else: response = {"status":200, "result":config.SPEAKER_MAX_PHRASES} return response @@ -1510,13 +1514,10 @@ class Controller: def setOscIpAddress(self, data, *args, **kwargs) -> dict: if isValidIpAddress(data) is False: - response = { - "status":400, - "result":{ - "message":"Invalid IP address", - "data": config.OSC_IP_ADDRESS - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_INVALID_IP, + data=config.OSC_IP_ADDRESS + ) else: try: model.setOscIpAddress(data) @@ -1533,13 +1534,10 @@ class Controller: response = {"status":200, "result":config.OSC_IP_ADDRESS} except Exception: model.setOscIpAddress(config.OSC_IP_ADDRESS) - response = { - "status":400, - "result":{ - "message":"Cannot set IP address", - "data": config.OSC_IP_ADDRESS - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_CANNOT_SET_IP, + data=config.OSC_IP_ADDRESS + ) return response @staticmethod @@ -1588,30 +1586,21 @@ class Controller: self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of deepL auth key", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_DEEPL_FAILED, + data=config.AUTH_KEYS[translator_name] + ) else: - response = { - "status":400, - "result":{ - "message":"DeepL auth key length is not correct", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_DEEPL_LENGTH, + data=config.AUTH_KEYS[translator_name] + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.AUTH_KEYS[translator_name] + ) return response def delDeeplAuthKey(self, *args, **kwargs) -> dict: @@ -1643,36 +1632,29 @@ class Controller: self.run(200, self.run_mapping["selectable_plamo_model_list"], config.SELECTABLE_PLAMO_MODEL_LIST) if config.SELECTED_PLAMO_MODEL not in config.SELECTABLE_PLAMO_MODEL_LIST: config.SELECTED_PLAMO_MODEL = config.SELECTABLE_PLAMO_MODEL_LIST[0] - model.setTranslatorPlamoModel(model=config.SELECTED_PLAMO_MODEL) - self.run(200, self.run_mapping["selected_plamo_model"], config.SELECTED_PLAMO_MODEL) + model.setTranslatorPlamoModel(model=config.SELECTED_PLAMO_MODEL) + self.run(200, self.run_mapping["selected_plamo_model"], config.SELECTED_PLAMO_MODEL) model.updateTranslatorPlamoClient() self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of plamo auth key", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_PLAMO_FAILED, + data=None + ) else: - response = { - "status":400, - "result":{ - "message":"Plamo auth key length is not correct", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_PLAMO_LENGTH, + data=None + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_exception_error_response( + e, + data=None + ) + if response["status"] == 400: + self.delPlamoAuthKey() return response def delPlamoAuthKey(self, *args, **kwargs) -> dict: @@ -1680,6 +1662,10 @@ class Controller: auth_keys = config.AUTH_KEYS auth_keys[translator_name] = None config.AUTH_KEYS = auth_keys + config.SELECTABLE_PLAMO_MODEL_LIST = [] + config.SELECTED_PLAMO_MODEL = None + self.run(200, self.run_mapping["selectable_plamo_model_list"], config.SELECTABLE_PLAMO_MODEL_LIST) + self.run(200, self.run_mapping["selected_plamo_model"], config.SELECTED_PLAMO_MODEL) config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False self.updateTranslationEngineAndEngineList() return {"status":200, "result":config.AUTH_KEYS[translator_name]} @@ -1701,22 +1687,16 @@ class Controller: model.updateTranslatorPlamoClient() response = {"status":200, "result":config.SELECTED_PLAMO_MODEL} else: - response = { - "status":400, - "result":{ - "message":"Plamo model is not valid", - "data": config.SELECTED_PLAMO_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_PLAMO_INVALID, + data=config.SELECTED_PLAMO_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_PLAMO_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_PLAMO_MODEL + ) return response def getGeminiAuthKey(self, *args, **kwargs) -> dict: @@ -1739,36 +1719,29 @@ class Controller: self.run(200, self.run_mapping["selectable_gemini_model_list"], config.SELECTABLE_GEMINI_MODEL_LIST) if config.SELECTED_GEMINI_MODEL not in config.SELECTABLE_GEMINI_MODEL_LIST: config.SELECTED_GEMINI_MODEL = config.SELECTABLE_GEMINI_MODEL_LIST[0] - model.setTranslatorGeminiModel(model=config.SELECTED_GEMINI_MODEL) - self.run(200, self.run_mapping["selected_gemini_model"], config.SELECTED_GEMINI_MODEL) + model.setTranslatorGeminiModel(model=config.SELECTED_GEMINI_MODEL) + self.run(200, self.run_mapping["selected_gemini_model"], config.SELECTED_GEMINI_MODEL) model.updateTranslatorGeminiClient() self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of gemini auth key", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_GEMINI_FAILED, + data=None + ) else: - response = { - "status":400, - "result":{ - "message":"Gemini auth key length is not correct", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_GEMINI_LENGTH, + data=None + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_exception_error_response( + e, + data=None + ) + if response["status"] == 400: + self.delGeminiAuthKey() return response def delGeminiAuthKey(self, *args, **kwargs) -> dict: @@ -1776,6 +1749,10 @@ class Controller: auth_keys = config.AUTH_KEYS auth_keys[translator_name] = None config.AUTH_KEYS = auth_keys + config.SELECTABLE_GEMINI_MODEL_LIST = [] + config.SELECTED_GEMINI_MODEL = None + self.run(200, self.run_mapping["selectable_gemini_model_list"], config.SELECTABLE_GEMINI_MODEL_LIST) + self.run(200, self.run_mapping["selected_gemini_model"], config.SELECTED_GEMINI_MODEL) config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False self.updateTranslationEngineAndEngineList() return {"status":200, "result":config.AUTH_KEYS[translator_name]} @@ -1797,22 +1774,16 @@ class Controller: model.updateTranslatorGeminiClient() response = {"status":200, "result":config.SELECTED_GEMINI_MODEL} else: - response = { - "status":400, - "result":{ - "message":"Gemini model is not valid", - "data": config.SELECTED_GEMINI_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_GEMINI_INVALID, + data=config.SELECTED_GEMINI_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_GEMINI_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_GEMINI_MODEL + ) return response @staticmethod @@ -1836,36 +1807,29 @@ class Controller: self.run(200, self.run_mapping["selectable_openai_model_list"], config.SELECTABLE_OPENAI_MODEL_LIST) if config.SELECTED_OPENAI_MODEL not in config.SELECTABLE_OPENAI_MODEL_LIST: config.SELECTED_OPENAI_MODEL = config.SELECTABLE_OPENAI_MODEL_LIST[0] - model.setTranslatorOpenAIModel(model=config.SELECTED_OPENAI_MODEL) - self.run(200, self.run_mapping["selected_openai_model"], config.SELECTED_OPENAI_MODEL) + model.setTranslatorOpenAIModel(model=config.SELECTED_OPENAI_MODEL) + self.run(200, self.run_mapping["selected_openai_model"], config.SELECTED_OPENAI_MODEL) model.updateTranslatorOpenAIClient() self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of OpenAI auth key", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_OPENAI_FAILED, + data=None + ) else: - response = { - "status":400, - "result":{ - "message":"OpenAI auth key is not valid", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_OPENAI_INVALID, + data=None + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_exception_error_response( + e, + data=None + ) + if response["status"] == 400: + self.delOpenAIAuthKey() return response def delOpenAIAuthKey(self, *args, **kwargs) -> dict: @@ -1873,6 +1837,10 @@ class Controller: auth_keys = config.AUTH_KEYS auth_keys[translator_name] = None config.AUTH_KEYS = auth_keys + config.SELECTABLE_OPENAI_MODEL_LIST = [] + config.SELECTED_OPENAI_MODEL = None + self.run(200, self.run_mapping["selectable_openai_model_list"], config.SELECTABLE_OPENAI_MODEL_LIST) + self.run(200, self.run_mapping["selected_openai_model"], config.SELECTED_OPENAI_MODEL) config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False self.updateTranslationEngineAndEngineList() return {"status":200, "result":config.AUTH_KEYS[translator_name]} @@ -1894,24 +1862,197 @@ class Controller: model.updateTranslatorOpenAIClient() response = {"status":200, "result":config.SELECTED_OPENAI_MODEL} else: - response = { - "status":400, - "result":{ - "message":"OpenAI model is not valid", - "data": config.SELECTED_OPENAI_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_OPENAI_INVALID, + data=config.SELECTED_OPENAI_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_OPENAI_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_OPENAI_MODEL + ) return response + @staticmethod + def getGroqAuthKey(*args, **kwargs) -> dict: + return {"status":200, "result":config.AUTH_KEYS["Groq_API"]} + + def setGroqAuthKey(self, data, *args, **kwargs) -> dict: + printLog("Set Groq Auth Key", data) + translator_name = "Groq_API" + try: + data = str(data) + if data.startswith("gsk") and len(data) >= 40: + result = model.authenticationTranslatorGroqAuthKey(auth_key=data) + if result is True: + key = data + auth_keys = config.AUTH_KEYS + auth_keys[translator_name] = key + config.AUTH_KEYS = auth_keys + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = True + config.SELECTABLE_GROQ_MODEL_LIST = model.getTranslatorGroqModelList() + self.run(200, self.run_mapping["selectable_groq_model_list"], config.SELECTABLE_GROQ_MODEL_LIST) + if config.SELECTED_GROQ_MODEL not in config.SELECTABLE_GROQ_MODEL_LIST: + config.SELECTED_GROQ_MODEL = config.SELECTABLE_GROQ_MODEL_LIST[0] + model.setTranslatorGroqModel(model=config.SELECTED_GROQ_MODEL) + self.run(200, self.run_mapping["selected_groq_model"], config.SELECTED_GROQ_MODEL) + model.updateTranslatorGroqClient() + self.updateTranslationEngineAndEngineList() + response = {"status":200, "result":config.AUTH_KEYS[translator_name]} + else: + response = VRCTError.create_error_response( + ErrorCode.AUTH_GROQ_FAILED, + data=None + ) + else: + response = VRCTError.create_error_response( + ErrorCode.AUTH_GROQ_INVALID, + data=None + ) + except Exception as e: + errorLogging() + response = VRCTError.create_exception_error_response( + e, + data=None + ) + if response["status"] == 400: + self.delGroqAuthKey() + return response + + def delGroqAuthKey(self, *args, **kwargs) -> dict: + translator_name = "Groq_API" + auth_keys = config.AUTH_KEYS + auth_keys[translator_name] = None + config.AUTH_KEYS = auth_keys + config.SELECTABLE_GROQ_MODEL_LIST = [] + config.SELECTED_GROQ_MODEL = None + self.run(200, self.run_mapping["selectable_groq_model_list"], config.SELECTABLE_GROQ_MODEL_LIST) + self.run(200, self.run_mapping["selected_groq_model"], config.SELECTED_GROQ_MODEL) + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False + self.updateTranslationEngineAndEngineList() + return {"status":200, "result":config.AUTH_KEYS[translator_name]} + + def getGroqModelList(self, *args, **kwargs) -> dict: + return {"status":200, "result": config.SELECTABLE_GROQ_MODEL_LIST} + + def getGroqModel(self, *args, **kwargs) -> dict: + return {"status":200, "result":config.SELECTED_GROQ_MODEL} + + def setGroqModel(self, data, *args, **kwargs) -> dict: + printLog("Set Groq Model", data) + try: + data = str(data) + result = model.setTranslatorGroqModel(model=data) + if result is True: + config.SELECTED_GROQ_MODEL = data + model.setTranslatorGroqModel(model=config.SELECTED_GROQ_MODEL) + model.updateTranslatorGroqClient() + response = {"status":200, "result":config.SELECTED_GROQ_MODEL} + else: + response = VRCTError.create_error_response( + ErrorCode.MODEL_GROQ_INVALID, + data=config.SELECTED_GROQ_MODEL + ) + except Exception as e: + errorLogging() + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_GROQ_MODEL + ) + return response + + @staticmethod + def getOpenRouterAuthKey(*args, **kwargs) -> dict: + return {"status":200, "result":config.AUTH_KEYS["OpenRouter_API"]} + + def setOpenRouterAuthKey(self, data, *args, **kwargs) -> dict: + printLog("Set OpenRouter Auth Key", data) + translator_name = "OpenRouter_API" + try: + data = str(data) + if len(data) >= 20: # OpenRouter API key basic validation + result = model.authenticationTranslatorOpenRouterAuthKey(auth_key=data) + if result is True: + key = data + auth_keys = config.AUTH_KEYS + auth_keys[translator_name] = key + config.AUTH_KEYS = auth_keys + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = True + config.SELECTABLE_OPENROUTER_MODEL_LIST = model.getTranslatorOpenRouterModelList() + self.run(200, self.run_mapping["selectable_openrouter_model_list"], config.SELECTABLE_OPENROUTER_MODEL_LIST) + if config.SELECTED_OPENROUTER_MODEL not in config.SELECTABLE_OPENROUTER_MODEL_LIST: + config.SELECTED_OPENROUTER_MODEL = config.SELECTABLE_OPENROUTER_MODEL_LIST[0] + model.setTranslatorOpenRouterModel(model=config.SELECTED_OPENROUTER_MODEL) + self.run(200, self.run_mapping["selected_openrouter_model"], config.SELECTED_OPENROUTER_MODEL) + model.updateTranslatorOpenRouterClient() + self.updateTranslationEngineAndEngineList() + response = {"status":200, "result":config.AUTH_KEYS[translator_name]} + else: + response = VRCTError.create_error_response( + ErrorCode.AUTH_OPENROUTER_FAILED, + data=None + ) + else: + response = VRCTError.create_error_response( + ErrorCode.AUTH_OPENROUTER_INVALID, + data=None + ) + except Exception as e: + errorLogging() + response = VRCTError.create_exception_error_response( + e, + data=None + ) + if response["status"] == 400: + self.delOpenRouterAuthKey() + return response + + def delOpenRouterAuthKey(self, *args, **kwargs) -> dict: + translator_name = "OpenRouter_API" + auth_keys = config.AUTH_KEYS + auth_keys[translator_name] = None + config.AUTH_KEYS = auth_keys + config.SELECTABLE_OPENROUTER_MODEL_LIST = [] + config.SELECTED_OPENROUTER_MODEL = None + self.run(200, self.run_mapping["selectable_openrouter_model_list"], config.SELECTABLE_OPENROUTER_MODEL_LIST) + self.run(200, self.run_mapping["selected_openrouter_model"], config.SELECTED_OPENROUTER_MODEL) + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False + self.updateTranslationEngineAndEngineList() + return {"status":200, "result":config.AUTH_KEYS[translator_name]} + + def getOpenRouterModelList(self, *args, **kwargs) -> dict: + return {"status":200, "result": config.SELECTABLE_OPENROUTER_MODEL_LIST} + + def getOpenRouterModel(self, *args, **kwargs) -> dict: + return {"status":200, "result":config.SELECTED_OPENROUTER_MODEL} + + def setOpenRouterModel(self, data, *args, **kwargs) -> dict: + printLog("Set OpenRouter Model", data) + try: + data = str(data) + result = model.setTranslatorOpenRouterModel(model=data) + if result is True: + config.SELECTED_OPENROUTER_MODEL = data + model.setTranslatorOpenRouterModel(model=config.SELECTED_OPENROUTER_MODEL) + model.updateTranslatorOpenRouterClient() + response = {"status":200, "result":config.SELECTED_OPENROUTER_MODEL} + else: + response = VRCTError.create_error_response( + ErrorCode.MODEL_OPENROUTER_INVALID, + data=config.SELECTED_OPENROUTER_MODEL + ) + except Exception as e: + errorLogging() + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_OPENROUTER_MODEL + ) + return response + + def getTranslatorLMStudioConnection(self, *args, **kwargs) -> dict: + return {"status":200, "result":model.getTranslatorLMStudioConnected()} + def checkTranslatorLMStudioConnection(self, *args, **kwargs) -> dict: printLog("Check Translator LMStudio Connection") translator_name = "LMStudio" @@ -1921,32 +2062,44 @@ class Controller: config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = True config.SELECTABLE_LMSTUDIO_MODEL_LIST = model.getTranslatorLMStudioModelList() self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) + if len(config.SELECTABLE_LMSTUDIO_MODEL_LIST) == 0: + raise Exception("No LMStudio models available") if config.SELECTED_LMSTUDIO_MODEL not in config.SELECTABLE_LMSTUDIO_MODEL_LIST: config.SELECTED_LMSTUDIO_MODEL = config.SELECTABLE_LMSTUDIO_MODEL_LIST[0] - model.setTranslatorLMStudioModel(model=config.SELECTED_LMSTUDIO_MODEL) - self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) + model.setTranslatorLMStudioModel(model=config.SELECTED_LMSTUDIO_MODEL) + self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) model.updateTranslatorLMStudioClient() self.updateTranslationEngineAndEngineList() response = {"status":200, "result":True} else: - response = { - "status":400, - "result":{ - "message":"Cannot connect to LMStudio server", - "data": False - } - } + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False + config.SELECTABLE_LMSTUDIO_MODEL_LIST = [] + config.SELECTED_LMSTUDIO_MODEL = None + self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) + self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) + self.updateTranslationEngineAndEngineList() + response = VRCTError.create_error_response( + ErrorCode.CONNECTION_LMSTUDIO_FAILED, + data=False + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": False - } - } + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False + config.SELECTABLE_LMSTUDIO_MODEL_LIST = [] + config.SELECTED_LMSTUDIO_MODEL = None + self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) + self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) + self.updateTranslationEngineAndEngineList() + response = VRCTError.create_exception_error_response( + e, + data=False + ) return response + def getConnectedLMStudio(self, *args, **kwargs) -> dict: + is_connected = model.getTranslatorLMStudioConnected() + return {"status":200, "result": is_connected} + def getTranslatorLMStudioURL(self, *args, **kwargs) -> dict: return {"status":200, "result":config.LMSTUDIO_URL} @@ -1961,30 +2114,38 @@ class Controller: config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = True config.SELECTABLE_LMSTUDIO_MODEL_LIST = model.getTranslatorLMStudioModelList() self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) + if len(config.SELECTABLE_LMSTUDIO_MODEL_LIST) == 0: + raise Exception("No LMStudio models available") if config.SELECTED_LMSTUDIO_MODEL not in config.SELECTABLE_LMSTUDIO_MODEL_LIST: config.SELECTED_LMSTUDIO_MODEL = config.SELECTABLE_LMSTUDIO_MODEL_LIST[0] - model.setTranslatorLMStudioModel(model=config.SELECTED_LMSTUDIO_MODEL) - self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) + model.setTranslatorLMStudioModel(model=config.SELECTED_LMSTUDIO_MODEL) + self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) model.updateTranslatorLMStudioClient() self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.LMSTUDIO_URL} else: - response = { - "status":400, - "result":{ - "message":"LMStudio URL is not valid", - "data": config.LMSTUDIO_URL - } - } + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False + config.SELECTABLE_LMSTUDIO_MODEL_LIST = [] + config.SELECTED_LMSTUDIO_MODEL = None + self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) + self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) + self.updateTranslationEngineAndEngineList() + response = VRCTError.create_error_response( + ErrorCode.CONNECTION_LMSTUDIO_URL_INVALID, + data=config.LMSTUDIO_URL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.LMSTUDIO_URL - } - } + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False + config.SELECTABLE_LMSTUDIO_MODEL_LIST = [] + config.SELECTED_LMSTUDIO_MODEL = None + self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) + self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) + self.updateTranslationEngineAndEngineList() + response = VRCTError.create_exception_error_response( + e, + data=config.LMSTUDIO_URL + ) return response def getTranslatorLStudioModelList(self, *args, **kwargs) -> dict: @@ -2005,24 +2166,21 @@ class Controller: model.updateTranslatorLMStudioClient() response = {"status":200, "result":config.SELECTED_LMSTUDIO_MODEL} else: - response = { - "status":400, - "result":{ - "message":"LMStudio model is not valid", - "data": config.SELECTED_LMSTUDIO_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_LMSTUDIO_INVALID, + data=config.SELECTED_LMSTUDIO_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_LMSTUDIO_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_LMSTUDIO_MODEL + ) return response + def getTranslatorOllamaConnection(self, *args, **kwargs) -> dict: + return {"status":200, "result":model.getTranslatorOllamaConnected()} + def checkTranslatorOllamaConnection(self, *args, **kwargs) -> dict: printLog("Check Translator Ollama Connection") translator_name = "Ollama" @@ -2032,30 +2190,38 @@ class Controller: config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = True config.SELECTABLE_OLLAMA_MODEL_LIST = model.getTranslatorOllamaModelList() self.run(200, self.run_mapping["selectable_ollama_model_list"], config.SELECTABLE_OLLAMA_MODEL_LIST) + if len(config.SELECTABLE_OLLAMA_MODEL_LIST) == 0: + raise Exception("No Ollama models available") if config.SELECTED_OLLAMA_MODEL not in config.SELECTABLE_OLLAMA_MODEL_LIST: config.SELECTED_OLLAMA_MODEL = config.SELECTABLE_OLLAMA_MODEL_LIST[0] - model.setTranslatorOllamaModel(model=config.SELECTED_OLLAMA_MODEL) - self.run(200, self.run_mapping["selected_ollama_model"], config.SELECTED_OLLAMA_MODEL) + model.setTranslatorOllamaModel(model=config.SELECTED_OLLAMA_MODEL) + self.run(200, self.run_mapping["selected_ollama_model"], config.SELECTED_OLLAMA_MODEL) model.updateTranslatorOllamaClient() self.updateTranslationEngineAndEngineList() response = {"status":200, "result":True} else: - response = { - "status":400, - "result":{ - "message":"Cannot connect to ollama server", - "data": False - } - } + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False + config.SELECTABLE_OLLAMA_MODEL_LIST = [] + config.SELECTED_OLLAMA_MODEL = None + self.run(200, self.run_mapping["selectable_ollama_model_list"], config.SELECTABLE_OLLAMA_MODEL_LIST) + self.run(200, self.run_mapping["selected_ollama_model"], config.SELECTED_OLLAMA_MODEL) + self.updateTranslationEngineAndEngineList() + response = VRCTError.create_error_response( + ErrorCode.CONNECTION_OLLAMA_FAILED, + data=False + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": False - } - } + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False + config.SELECTABLE_OLLAMA_MODEL_LIST = [] + config.SELECTED_OLLAMA_MODEL = None + self.run(200, self.run_mapping["selectable_ollama_model_list"], config.SELECTABLE_OLLAMA_MODEL_LIST) + self.run(200, self.run_mapping["selected_ollama_model"], config.SELECTED_OLLAMA_MODEL) + self.updateTranslationEngineAndEngineList() + response = VRCTError.create_exception_error_response( + e, + data=False + ) return response def getTranslatorOllamaModelList(self, *args, **kwargs) -> dict: @@ -2076,22 +2242,16 @@ class Controller: model.updateTranslatorOllamaClient() response = {"status":200, "result":config.SELECTED_OLLAMA_MODEL} else: - response = { - "status":400, - "result":{ - "message":"ollama model is not valid", - "data": config.SELECTED_OLLAMA_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_OLLAMA_INVALID, + data=config.SELECTED_OLLAMA_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_OLLAMA_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_OLLAMA_MODEL + ) return response @staticmethod @@ -2323,13 +2483,10 @@ class Controller: model.changeMicTranscriptStatus() response = {"status":200, "result":config.VRC_MIC_MUTE_SYNC} else: - response = { - "status":400, - "result":{ - "message":"Cannot enable VRC mic mute sync while OSC query is disabled", - "data": config.VRC_MIC_MUTE_SYNC - } - } + response = VRCTError.create_error_response( + ErrorCode.VRC_MIC_MUTE_SYNC_OSC_DISABLED, + data=config.VRC_MIC_MUTE_SYNC + ) else: response = {"status":200, "result":config.VRC_MIC_MUTE_SYNC} return response @@ -2538,23 +2695,25 @@ class Controller: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + response = VRCTError.create_error_response( + ErrorCode.TRANSCRIPTION_VRAM_MIC, + data=error_message + ) self.run( - 400, + response["status"], self.run_mapping["error_transcription_mic_vram_overflow"], - { - "message":"VRAM out of memory during mic transcription", - "data": error_message - }, + response["result"], ) # ここでマイクの音声認識を停止 self.stopTranscriptionSendMessage() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSCRIPTION_SEND_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_transcription_send"], - { - "message":"Transcription send disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) else: # その他のエラーは通常通り処理 @@ -2587,23 +2746,25 @@ class Controller: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + response = VRCTError.create_error_response( + ErrorCode.TRANSCRIPTION_VRAM_SPEAKER, + data=error_message + ) self.run( - 400, + response["status"], self.run_mapping["error_transcription_speaker_vram_overflow"], - { - "message":"VRAM out of memory during speaker transcription", - "data": error_message - }, + response["result"], ) # ここでスピーカーの音声認識を停止 self.stopTranscriptionReceiveMessage() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSCRIPTION_RECEIVE_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_transcription_receive"], - { - "message":"Transcription receive disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) else: # その他のエラーは通常通り処理 @@ -2667,8 +2828,14 @@ class Controller: return cleaned_text def updateDownloadedCTranslate2ModelWeight(self) -> None: - for weight_type in config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT.keys(): - config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT[weight_type] = model.checkTranslatorCTranslate2ModelWeight(weight_type) + # キャッシュされた結果を使用(起動時の重複チェックを回避) + if hasattr(self, '_ctranslate2_available_cache'): + # 起動時のキャッシュを使用: 選択中の重みタイプのみ設定 + config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT[config.CTRANSLATE2_WEIGHT_TYPE] = self._ctranslate2_available_cache + else: + # 通常時は全重みタイプをチェック + for weight_type in config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT.keys(): + config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT[weight_type] = model.checkTranslatorCTranslate2ModelWeight(weight_type) def updateTranslationEngineAndEngineList(self): engines = config.SELECTED_TRANSLATION_ENGINES @@ -2690,8 +2857,14 @@ class Controller: self.run(200, self.run_mapping["translation_engines"], selectable_engines) def updateDownloadedWhisperModelWeight(self) -> None: - for weight_type in config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT.keys(): - config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT[weight_type] = model.checkTranscriptionWhisperModelWeight(weight_type) + # キャッシュされた結果を使用(起動時の重複チェックを回避) + if hasattr(self, '_whisper_available_cache'): + # 起動時のキャッシュを使用: 選択中の重みタイプのみ設定 + config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT[config.WHISPER_WEIGHT_TYPE] = self._whisper_available_cache + else: + # 通常時は全重みタイプをチェック + for weight_type in config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT.keys(): + config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT[weight_type] = model.checkTranscriptionWhisperModelWeight(weight_type) def updateTranscriptionEngine(self): weight_type = config.WHISPER_WEIGHT_TYPE @@ -2792,13 +2965,10 @@ class Controller: @staticmethod def setWebSocketHost(data, *args, **kwargs) -> dict: if isValidIpAddress(data) is False: - response = { - "status":400, - "result":{ - "message":"Invalid IP address", - "data": config.WEBSOCKET_HOST - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_INVALID_IP, + data=config.WEBSOCKET_HOST + ) else: if model.checkWebSocketServerAlive() is False: config.WEBSOCKET_HOST = data @@ -2812,13 +2982,10 @@ class Controller: config.WEBSOCKET_HOST = data response = {"status":200, "result":config.WEBSOCKET_HOST} else: - response = { - "status":400, - "result":{ - "message":"WebSocket server host is not available", - "data": config.WEBSOCKET_HOST - } - } + response = VRCTError.create_error_response( + ErrorCode.WEBSOCKET_HOST_UNAVAILABLE, + data=config.WEBSOCKET_HOST + ) return response @@ -2840,13 +3007,10 @@ class Controller: config.WEBSOCKET_PORT = int(data) response = {"status":200, "result":config.WEBSOCKET_PORT} else: - response = { - "status":400, - "result":{ - "message":"WebSocket server port is not available", - "data": config.WEBSOCKET_PORT - } - } + response = VRCTError.create_error_response( + ErrorCode.WEBSOCKET_PORT_UNAVAILABLE, + data=config.WEBSOCKET_PORT + ) return response @staticmethod @@ -2861,13 +3025,10 @@ class Controller: config.WEBSOCKET_SERVER = True response = {"status":200, "result":config.WEBSOCKET_SERVER} else: - response = { - "status":400, - "result":{ - "message":"WebSocket server host or port is not available", - "data": config.WEBSOCKET_SERVER - } - } + response = VRCTError.create_error_response( + ErrorCode.WEBSOCKET_SERVER_UNAVAILABLE, + data=config.WEBSOCKET_SERVER + ) else: response = {"status":200, "result":config.WEBSOCKET_SERVER} return response @@ -2937,6 +3098,8 @@ class Controller: def init(self, *args, **kwargs) -> None: removeLog() printLog("Start Initialization") + + # Network check connected_network = isConnectedNetwork() if connected_network is True: self.connectedNetwork() @@ -2946,8 +3109,8 @@ class Controller: self.initializationProgress(1) + # Download weights if connected_network is True: - # download CTranslate2 Model Weight printLog("Download CTranslate2 Model Weight") weight_type = config.CTRANSLATE2_WEIGHT_TYPE th_download_ctranslate2 = None @@ -2956,7 +3119,6 @@ class Controller: th_download_ctranslate2.daemon = True th_download_ctranslate2.start() - # download Whisper Model Weight printLog("Download Whisper Model Weight") weight_type = config.WHISPER_WEIGHT_TYPE th_download_whisper = None @@ -2970,127 +3132,244 @@ class Controller: if isinstance(th_download_whisper, Thread): th_download_whisper.join() - if (model.checkTranslatorCTranslate2ModelWeight(config.CTRANSLATE2_WEIGHT_TYPE) is False or - model.checkTranscriptionWhisperModelWeight(config.WHISPER_WEIGHT_TYPE) is False): + # Check and disable/enable AI models (parallel) + + def check_ctranslate2() -> bool: + return model.checkTranslatorCTranslate2ModelWeight(config.CTRANSLATE2_WEIGHT_TYPE) is True + + def check_whisper() -> bool: + return model.checkTranscriptionWhisperModelWeight(config.WHISPER_WEIGHT_TYPE) is True + + with ThreadPoolExecutor(max_workers=2) as executor: + future_ctranslate2 = executor.submit(check_ctranslate2) + future_whisper = executor.submit(check_whisper) + ctranslate2_available = future_ctranslate2.result() + whisper_available = future_whisper.result() + + # インスタンス変数にキャッシュ(後続の処理で再利用) + self._ctranslate2_available_cache = ctranslate2_available + self._whisper_available_cache = whisper_available + + if not ctranslate2_available or not whisper_available: self.disableAiModels() else: self.enableAiModels() + # Init Translation Engine Status (with parallel processing) printLog("Init Translation Engine Status") - for engine in config.SELECTABLE_TRANSLATION_ENGINE_LIST: - match engine: - case "CTranslate2": - if model.checkTranslatorCTranslate2ModelWeight(config.CTRANSLATE2_WEIGHT_TYPE) is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - else: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - case "DeepL_API": - printLog("Start check DeepL API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorDeepLAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("DeepL API Key is valid") + + # バックグラウンドチェック対象エンジン(LMStudio/Ollama) + background_check_engines = {"LMStudio", "Ollama"} + + def check_translation_engine(engine: str) -> tuple: + """翻訳エンジンのステータスをチェック(並列実行用)""" + status = False + auth_key_invalid = False + model_list = None + selected_model = None + + try: + match engine: + case "CTranslate2": + # 既に前のステップでチェック済み、結果を再利用 + status = ctranslate2_available + case "DeepL_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("DeepL API Key is invalid") - case "Plamo_API": - printLog("Start check Plamo API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorPlamoAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("Plamo API Key is valid") - config.SELECTABLE_PLAMO_MODEL_LIST = model.getTranslatorPlamoModelList() - if config.SELECTED_PLAMO_MODEL not in config.SELECTABLE_PLAMO_MODEL_LIST: - config.SELECTED_PLAMO_MODEL = config.SELECTABLE_PLAMO_MODEL_LIST[0] - model.setTranslatorPlamoModel(config.SELECTED_PLAMO_MODEL) - model.updateTranslatorPlamoClient() + if model.authenticationTranslatorDeepLAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + status = True + else: + auth_key_invalid = True + case "Plamo_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("Plamo API Key is invalid") - case "Gemini_API": - printLog("Start check Gemini API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorGeminiAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("Gemini API Key is valid") - config.SELECTABLE_GEMINI_MODEL_LIST = model.getTranslatorGeminiModelList() - if config.SELECTED_GEMINI_MODEL not in config.SELECTABLE_GEMINI_MODEL_LIST: - config.SELECTED_GEMINI_MODEL = config.SELECTABLE_GEMINI_MODEL_LIST[0] - model.setTranslatorGeminiModel(config.SELECTED_GEMINI_MODEL) - model.updateTranslatorGeminiClient() + if model.authenticationTranslatorPlamoAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorPlamoModelList() + selected_model = config.SELECTED_PLAMO_MODEL if config.SELECTED_PLAMO_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "Gemini_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("Gemini API Key is invalid") - case "OpenAI_API": - printLog("Start check OpenAI API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorOpenAIAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("OpenAI API Key is valid") - config.SELECTABLE_OPENAI_MODEL_LIST = model.getTranslatorOpenAIModelList() - if config.SELECTED_OPENAI_MODEL not in config.SELECTABLE_OPENAI_MODEL_LIST: - config.SELECTED_OPENAI_MODEL = config.SELECTABLE_OPENAI_MODEL_LIST[0] - model.setTranslatorOpenAIModel(config.SELECTED_OPENAI_MODEL) - model.updateTranslatorOpenAIClient() + if model.authenticationTranslatorGeminiAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorGeminiModelList() + selected_model = config.SELECTED_GEMINI_MODEL if config.SELECTED_GEMINI_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "OpenAI_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("OpenAI API Key is invalid") - case "LMStudio": - printLog("Start check LMStudio API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False + if model.authenticationTranslatorOpenAIAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorOpenAIModelList() + selected_model = config.SELECTED_OPENAI_MODEL if config.SELECTED_OPENAI_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "Groq_API": + if config.AUTH_KEYS[engine] is None: + status = False + else: + if model.authenticationTranslatorGroqAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorGroqModelList() + selected_model = config.SELECTED_GROQ_MODEL if config.SELECTED_GROQ_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "OpenRouter_API": + if config.AUTH_KEYS[engine] is None: + status = False + else: + if model.authenticationTranslatorOpenRouterAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorOpenRouterModelList() + selected_model = config.SELECTED_OPENROUTER_MODEL if config.SELECTED_OPENROUTER_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "LMStudio": + # バックグラウンドチェックにスキップ + status = False + case "Ollama": + # バックグラウンドチェックにスキップ + status = False + case _: + status = connected_network is True + except Exception as e: + printLog(f"Error checking engine {engine}: {str(e)}") + errorLogging() + status = False + + return engine, status, auth_key_invalid, model_list, selected_model + + def check_local_server_engine_background(engine: str): + """ローカルサーバー系エンジンをバックグラウンドでチェック""" + try: + printLog(f"[Background] Start check {engine}") + status = False + model_list = None + selected_model = None + + if engine == "LMStudio": if config.LMSTUDIO_URL is not None: if model.authenticationTranslatorLMStudio(base_url=config.LMSTUDIO_URL) is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("LMStudio URL is valid") - config.SELECTABLE_LMSTUDIO_MODEL_LIST = model.getTranslatorLMStudioModelList() - if config.SELECTED_LMSTUDIO_MODEL not in config.SELECTABLE_LMSTUDIO_MODEL_LIST: - config.SELECTED_LMSTUDIO_MODEL = config.SELECTABLE_LMSTUDIO_MODEL_LIST[0] - model.setTranslatorLMStudioModel(config.SELECTED_LMSTUDIO_MODEL) - model.updateTranslatorLMStudioClient() - else: - printLog("LMStudio is not available") - case "Ollama": - printLog("Start check Ollama API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False + model_list = model.getTranslatorLMStudioModelList() + if len(model_list) > 0: + selected_model = config.SELECTED_LMSTUDIO_MODEL if config.SELECTED_LMSTUDIO_MODEL in model_list else model_list[0] + config.SELECTABLE_LMSTUDIO_MODEL_LIST = model_list + config.SELECTED_LMSTUDIO_MODEL = selected_model + model.setTranslatorLMStudioModel(selected_model) + model.updateTranslatorLMStudioClient() + status = True + elif engine == "Ollama": if model.authenticationTranslatorOllama() is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("Ollama is available") - config.SELECTABLE_OLLAMA_MODEL_LIST = model.getTranslatorOllamaModelList() - if config.SELECTED_OLLAMA_MODEL not in config.SELECTABLE_OLLAMA_MODEL_LIST: - config.SELECTED_OLLAMA_MODEL = config.SELECTABLE_OLLAMA_MODEL_LIST[0] - model.setTranslatorOllamaModel(config.SELECTED_OLLAMA_MODEL) - model.updateTranslatorOllamaClient() - else: - printLog("Ollama is not available") - case _: - if connected_network is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - else: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False + model_list = model.getTranslatorOllamaModelList() + if len(model_list) > 0: + selected_model = config.SELECTED_OLLAMA_MODEL if config.SELECTED_OLLAMA_MODEL in model_list else model_list[0] + config.SELECTABLE_OLLAMA_MODEL_LIST = model_list + config.SELECTED_OLLAMA_MODEL = selected_model + model.setTranslatorOllamaModel(selected_model) + model.updateTranslatorOllamaClient() + status = True + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = status + printLog(f"[Background] {engine} check completed: {status}") + + # 更新通知(もしrun_mappingがあれば) + if status: + self.updateTranslationEngineAndEngineList() + except Exception as e: + printLog(f"[Background] Error checking {engine}: {str(e)}") + errorLogging() + + # 並列実行(バックグラウンドチェック対象を除外) + engine_results = {} + engines_to_check = [e for e in config.SELECTABLE_TRANSLATION_ENGINE_LIST if e not in background_check_engines] + + with ThreadPoolExecutor(max_workers=4) as executor: + future_to_engine = {executor.submit(check_translation_engine, engine): engine + for engine in engines_to_check} + + for future in as_completed(future_to_engine): + engine, status, auth_key_invalid, model_list, selected_model = future.result() + engine_results[engine] = (status, auth_key_invalid, model_list, selected_model) + + # バックグラウンドチェック対象エンジンは初期値Falseで即座に設定 + for engine in background_check_engines: + if engine in config.SELECTABLE_TRANSLATION_ENGINE_LIST: + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False + printLog(f"Start check {engine}") + printLog(f"Engine '{engine}' deferred to background check") + # バックグラウンドスレッドで実行 + bg_thread = Thread(target=check_local_server_engine_background, args=(engine,)) + bg_thread.daemon = True + bg_thread.start() + + # 結果を順番に適用(メインスレッドで実行) + for engine in engines_to_check: + if engine not in engine_results: + continue + + status, auth_key_invalid, model_list, selected_model = engine_results[engine] + + # ログ出力 + printLog(f"Start check {engine}") + + # ステータス設定 + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = status + + # 認証キー無効化 + if auth_key_invalid: + auth_keys = config.AUTH_KEYS + auth_keys[engine] = None + config.AUTH_KEYS = auth_keys + printLog(f"{engine} auth key is invalid") + elif status: + printLog(f"{engine} is valid/available") + + # モデルリストと選択モデルの設定 + if model_list is not None and status: + match engine: + case "Plamo_API": + config.SELECTABLE_PLAMO_MODEL_LIST = model_list + config.SELECTED_PLAMO_MODEL = selected_model + model.setTranslatorPlamoModel(selected_model) + model.updateTranslatorPlamoClient() + case "Gemini_API": + config.SELECTABLE_GEMINI_MODEL_LIST = model_list + config.SELECTED_GEMINI_MODEL = selected_model + model.setTranslatorGeminiModel(selected_model) + model.updateTranslatorGeminiClient() + case "OpenAI_API": + config.SELECTABLE_OPENAI_MODEL_LIST = model_list + config.SELECTED_OPENAI_MODEL = selected_model + model.setTranslatorOpenAIModel(selected_model) + model.updateTranslatorOpenAIClient() + case "Groq_API": + config.SELECTABLE_GROQ_MODEL_LIST = model_list + config.SELECTED_GROQ_MODEL = selected_model + model.setTranslatorGroqModel(selected_model) + model.updateTranslatorGroqClient() + case "OpenRouter_API": + config.SELECTABLE_OPENROUTER_MODEL_LIST = model_list + config.SELECTED_OPENROUTER_MODEL = selected_model + model.setTranslatorOpenRouterModel(selected_model) + model.updateTranslatorOpenRouterClient() + + printLog(f"{engine} check completed") + + printLog("Translation Engine Status Init completed") + + # Init Transcription Engine Status for engine in config.SELECTABLE_TRANSCRIPTION_ENGINE_LIST: match engine: case "Whisper": - if model.checkTranscriptionWhisperModelWeight(config.WHISPER_WEIGHT_TYPE) is True: - config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = True - else: - config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = False + # キャッシュされた結果を使用(重複チェックを回避) + config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = self._whisper_available_cache case _: if connected_network is True: config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = True @@ -3098,55 +3377,79 @@ class Controller: config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = False self.initializationProgress(2) - # set Translation Engine + # Set Translation Engine printLog("Set Translation Engine") self.updateDownloadedCTranslate2ModelWeight() self.updateTranslationEngineAndEngineList() - # set Transcription Engine + # Set Transcription Engine printLog("Set Transcription Engine") self.updateDownloadedWhisperModelWeight() self.updateTranscriptionEngine() - # set Transliteration status + # Set Transliteration printLog("Set Transliteration") if config.CONVERT_MESSAGE_TO_ROMAJI is True or config.CONVERT_MESSAGE_TO_HIRAGANA is True: model.startTransliteration() self.initializationProgress(3) - # set word filter + # Set Word Filter printLog("Set Word Filter") model.addKeywords() - # check Software Updated - printLog("Check Software Updated") - self.checkSoftwareUpdated() + # Check Software Updated (Background) + printLog("Check Software Updated (Background)") - # init logger + def check_software_updated_background(): + """ソフトウェア更新チェックをバックグラウンドで実行""" + try: + self.checkSoftwareUpdated() + printLog("[Background] Software update check completed") + except Exception: + errorLogging() + printLog("[Background] Software update check failed") + + bg_thread = Thread(target=check_software_updated_background) + bg_thread.daemon = True + bg_thread.start() + + # Init Logger printLog("Init Logger") if config.LOGGER_FEATURE is True: model.startLogger() self.initializationProgress(4) - # init OSC receive - printLog("Init OSC Receive") - model.startReceiveOSC() - osc_query_enabled = model.getIsOscQueryEnabled() - if osc_query_enabled is True: - self.enableOscQuery() - if config.VRC_MIC_MUTE_SYNC is True: - self.setEnableVrcMicMuteSync() - else: - # OSC Query is disabled, so disable VRC some features - mute_sync_info_flag = False - if config.VRC_MIC_MUTE_SYNC is True: - self.setDisableVrcMicMuteSync() - mute_sync_info_flag = True - self.disableOscQuery(mute_sync_info=mute_sync_info_flag) + # Init OSC Receive (Background) + printLog("Init OSC Receive (Background)") - # init Auto device selection + def init_osc_receive_background(): + """OSC Receiveの初期化をバックグラウンドで実行""" + try: + model.startReceiveOSC() + osc_query_enabled = model.getIsOscQueryEnabled() + if osc_query_enabled is True: + self.enableOscQuery() + if config.VRC_MIC_MUTE_SYNC is True: + self.setEnableVrcMicMuteSync() + else: + # OSC Query is disabled, so disable VRC some features + mute_sync_info_flag = False + if config.VRC_MIC_MUTE_SYNC is True: + self.setDisableVrcMicMuteSync() + mute_sync_info_flag = True + self.disableOscQuery(mute_sync_info=mute_sync_info_flag) + printLog("[Background] OSC Receive initialization completed") + except Exception: + errorLogging() + printLog("[Background] OSC Receive initialization failed") + + bg_thread = Thread(target=init_osc_receive_background) + bg_thread.daemon = True + bg_thread.start() + + # Init Device Manager printLog("Init Device Manager") device_manager.setCallbackHostList(self.updateMicHostList) device_manager.setCallbackMicDeviceList(self.updateMicDeviceList) @@ -3158,10 +3461,12 @@ class Controller: if config.AUTO_SPEAKER_SELECT is True: self.applyAutoSpeakerSelect() + # Init Overlay printLog("Init Overlay") if (config.OVERLAY_SMALL_LOG is True or config.OVERLAY_LARGE_LOG is True): model.startOverlay() + # Init WebSocket Server printLog("Init WebSocket Server") if config.WEBSOCKET_SERVER is True: if isAvailableWebSocketServer(config.WEBSOCKET_HOST, config.WEBSOCKET_PORT) is True: @@ -3171,6 +3476,11 @@ class Controller: model.stopWebSocketServer() printLog("WebSocket server host or port is not available") + # Revalidate Selected Models + printLog("Revalidate Selected Models") + config.revalidate_selected_models() + + # Update Settings printLog("Update settings") self.updateConfigSettings() diff --git a/src-python/docs/config.md b/src-python/docs/config.md index 7d7d3f6e..783772fa 100644 --- a/src-python/docs/config.md +++ b/src-python/docs/config.md @@ -216,11 +216,17 @@ _debounce_time: int = 2 # デバウンス時間(秒) **通信設定** - `OSC_IP_ADDRESS`: OSC IPアドレス(デフォルト: "127.0.0.1") - `OSC_PORT`: OSCポート(デフォルト: 9000) -- `AUTH_KEYS`: 認証キー辞書(DeepL API等) +- `AUTH_KEYS`: 認証キー辞書(DeepL API, Groq API, OpenRouter API等) - `WEBSOCKET_HOST`: WebSocketホスト - `WEBSOCKET_PORT`: WebSocketポート - `WEBSOCKET_SERVER`: WebSocketサーバー有効フラグ(非永続化) +**翻訳エンジン モデル選択** +- `SELECTABLE_GROQ_MODEL_LIST`: 利用可能な Groq モデルリスト(非永続化) +- `SELECTED_GROQ_MODEL`: 選択中の Groq モデル +- `SELECTABLE_OPENROUTER_MODEL_LIST`: 利用可能な OpenRouter モデルリスト(非永続化) +- `SELECTED_OPENROUTER_MODEL`: 選択中の OpenRouter モデル + **オーバーレイ設定** - `OVERLAY_SMALL_LOG`: 小ログオーバーレイ有効 - `OVERLAY_SMALL_LOG_SETTINGS`: 小ログオーバーレイ設定(位置、回転、表示時間等) @@ -273,11 +279,21 @@ def SELECTED_TAB_NO(self, value): ``` 各setterは以下のパターンを実装: -1. 型チェック (`isinstance`) +1. 型チェック (`isinstance`):`ManagedProperty` による型チェックは `None` を許容するが、個別 setter が数値変換などを行う場合は `None` を拒否するケースがある 2. 値の範囲・有効性チェック 3. 内部変数への代入 4. `saveConfig` 呼び出し(永続化対象の場合) +#### 型チェックの詳細(v3.3.0+) + +```python +# 型チェック実装:ManagedProperty 経由では None を常に許可 +if self.type_ is not None and value is not None and not isinstance(value, self.type_): + return # 無視する +``` + +この仕様は `ManagedProperty` を通じた型チェックに適用される。個別の setter で追加のバリデーションやキャストを行う場合、`None` は別途拒否されることがある。 + ### メッセージフォーマット構造 ```python diff --git a/src-python/docs/controller.md b/src-python/docs/controller.md index d27ebb08..d34dc9e3 100644 --- a/src-python/docs/controller.md +++ b/src-python/docs/controller.md @@ -4,6 +4,15 @@ `controller.py` は VRCT アプリケーションのビジネスロジック層であり、フロントエンド(UI)とバックエンド(Model)の間の制御フローを担当する。音声認識、翻訳、OSC通信、オーバーレイ表示など、VRCT の全機能の調整役として動作し、各種設定の取得・更新、デバイス管理、エラーハンドリングを提供する。 +## 最近の更新 (2026-01-03) + +- 起動高速化: 初期化時間を約12.6s→8.9sに短縮 +- AI Models Check 並列化: CTranslate2/Whisperの重みチェックを2並列で実行 +- 翻訳エンジン判定の非同期化: LMStudio/Ollamaをバックグラウンド判定、他APIは4並列 +- 重みチェック結果のキャッシュ: `_ctranslate2_available_cache` / `_whisper_available_cache` を導入し後続処理で再利用 +- 音声認識エンジン判定の高速化: Whisperはキャッシュ結果を利用し0.56s→0.00s +- ソフトウェア更新チェックの非同期化: GitHub APIチェックをバックグラウンド化 + ## アーキテクチャ上の位置づけ ``` @@ -693,7 +702,13 @@ OSC Query 機能が無効になったことを通知。無効化された機能 - `config.AUTH_KEYS["DeepL_API"]` に保存 - `config.SELECTABLE_TRANSLATION_ENGINE_STATUS["DeepL_API"]` を True に - `updateTranslationEngineAndEngineList()` を呼び出し -4. 認証失敗時: status 400 を返却 +4. 認証失敗時 (status 400): + - レスポンス `data` フィールドは **常に None**(キーを返さない) + - `delDeeplAuthKey()` を呼び出してクリーンアップ + +**認証失敗時の共通ポリシー(Plamo/Gemini/OpenAI/DeepL/Groq/OpenRouter 共通)** +- レスポンス `data` はキーを含めず `None` を返す +- 対応する `del*AuthKey()` を呼び出し、保存済みキーとモデル選択をクリア #### `delDeeplAuthKey(*args, **kwargs) -> dict` @@ -706,6 +721,118 @@ OSC Query 機能が無効になったことを通知。無効化された機能 --- +### 16-1. Groq API 認証・モデル管理 + +#### `setGroqAuthKey(data, *args, **kwargs) -> dict` + +**責務:** Groq API キーを設定し、認証を実行 + +**処理:** +1. キー長のバリデーション(`gsk` で始まり40文字以上) +2. `model.authenticationTranslatorGroqAuthKey()` で認証 +3. 認証成功時: + - `config.AUTH_KEYS["Groq_API"]` に保存 + - `config.SELECTABLE_TRANSLATION_ENGINE_STATUS["Groq_API"]` を True に + - `config.SELECTABLE_GROQ_MODEL_LIST` を取得 + - 未選択の場合は先頭モデルを自動選択 + - `model.updateTranslatorGroqClient()` でクライアント更新 + - `updateTranslationEngineAndEngineList()` を呼び出し +4. 認証失敗時 (status 400): + - レスポンス `data` フィールドを **None に設定** (sensitive data を隠す) + - `delGroqAuthKey()` を呼び出してクリーンアップ + +**API キー検証失敗時の処理:** +- モデルリストをクリア (`config.SELECTABLE_GROQ_MODEL_LIST = []`) +- 選択モデルをクリア (`config.SELECTED_GROQ_MODEL = None`) +- フロントエンドに通知(レスポンス `data` は None) + +#### `delGroqAuthKey(*args, **kwargs) -> dict` + +**責務:** Groq API キーを削除 + +**処理:** +1. `config.AUTH_KEYS["Groq_API"]` を None に +2. `config.SELECTABLE_TRANSLATION_ENGINE_STATUS["Groq_API"]` を False に +3. モデルリストと選択モデルをクリア +4. `updateTranslationEngineAndEngineList()` を呼び出し + +#### `getGroqAuthKey(*args, **kwargs) -> dict` +現在の Groq API キーを取得(マスク処理なし)。 + +#### `getGroqModelList(*args, **kwargs) -> dict` +利用可能な Groq モデルリストを取得。 + +#### `getGroqModel(*args, **kwargs) -> dict` +現在選択中の Groq モデルを取得。 + +#### `setGroqModel(data, *args, **kwargs) -> dict` + +**責務:** 使用する Groq モデルを変更 + +**処理:** +1. モデル名のバリデーション(利用可能リスト内か確認) +2. `model.setTranslatorGroqModel()` でモデル設定 +3. `model.updateTranslatorGroqClient()` でクライアント再生成 +4. `config.SELECTED_GROQ_MODEL` を更新 + +--- + +### 16-2. OpenRouter API 認証・モデル管理 + +#### `setOpenRouterAuthKey(data, *args, **kwargs) -> dict` + +**責務:** OpenRouter API キーを設定し、認証を実行 + +**処理:** +1. キー長のバリデーション(20文字以上) +2. `model.authenticationTranslatorOpenRouterAuthKey()` で認証 +3. 認証成功時: + - `config.AUTH_KEYS["OpenRouter_API"]` に保存 + - `config.SELECTABLE_TRANSLATION_ENGINE_STATUS["OpenRouter_API"]` を True に + - `config.SELECTABLE_OPENROUTER_MODEL_LIST` を取得 + - 未選択の場合は先頭モデルを自動選択 + - `model.updateTranslatorOpenRouterClient()` でクライアント更新 + - `updateTranslationEngineAndEngineList()` を呼び出し +4. 認証失敗時 (status 400): + - レスポンス `data` フィールドを **None に設定** (sensitive data を隠す) + - `delOpenRouterAuthKey()` を呼び出してクリーンアップ + +**API キー検証失敗時の処理:** +- モデルリストをクリア (`config.SELECTABLE_OPENROUTER_MODEL_LIST = []`) +- 選択モデルをクリア (`config.SELECTED_OPENROUTER_MODEL = None`) +- フロントエンドに通知(レスポンス `data` は None) + +#### `delOpenRouterAuthKey(*args, **kwargs) -> dict` + +**責務:** OpenRouter API キーを削除 + +**処理:** +1. `config.AUTH_KEYS["OpenRouter_API"]` を None に +2. `config.SELECTABLE_TRANSLATION_ENGINE_STATUS["OpenRouter_API"]` を False に +3. モデルリストと選択モデルをクリア +4. `updateTranslationEngineAndEngineList()` を呼び出し + +#### `getOpenRouterAuthKey(*args, **kwargs) -> dict` +現在の OpenRouter API キーを取得(マスク処理なし)。 + +#### `getOpenRouterModelList(*args, **kwargs) -> dict` +利用可能な OpenRouter モデルリストを取得。 + +#### `getOpenRouterModel(*args, **kwargs) -> dict` +現在選択中の OpenRouter モデルを取得。 + +#### `setOpenRouterModel(data, *args, **kwargs) -> dict` + +**責務:** 使用する OpenRouter モデルを変更 + +**処理:** +1. モデル名のバリデーション(利用可能リスト内か確認) +2. `model.setTranslatorOpenRouterModel()` でモデル設定 +3. `model.updateTranslatorOpenRouterClient()` でクライアント再生成 +4. `config.SELECTED_OPENROUTER_MODEL` を更新 + +--- + ### 17. WebSocket サーバー制御 #### `setWebSocketHost(data, *args, **kwargs) -> dict` diff --git a/src-python/docs/details/controller.md b/src-python/docs/details/controller.md index 3dd3e73b..79f7e751 100644 --- a/src-python/docs/details/controller.md +++ b/src-python/docs/details/controller.md @@ -4,6 +4,26 @@ VRCTアプリケーションのビジネスロジックを制御するコントローラークラスです。UI層とモデル層の間に位置し、ユーザーの入力を適切な処理に変換し、結果を UI に返す役割を担います。全ての機能制御、設定管理、状態管理を一元的に行います。 +## 最近の更新 (2026-01-03) + +### 起動高速化・非同期化 + +- 初期化時間を約12.6s→8.9sに短縮(環境計測値) +- AI Models Check を2並列化(CTranslate2/Whisper)し、結果を `_ctranslate2_available_cache` / `_whisper_available_cache` に保存 +- 翻訳エンジン判定を並列化(ThreadPoolExecutor, max_workers=4)し、LMStudio/Ollamaはバックグラウンド判定に変更 +- ソフトウェア更新チェックをバックグラウンド化 +- OSC受信初期化をバックグラウンド化し、OSCQueryサービス生成は接続成功まで継続リトライ +- 翻訳/音声認識エンジンのセット処理で重みチェックキャッシュを再利用し再計測を排除(0.98s/0.52s→0.00s) + +### 影響 + +| 項目 | 内容 | +|------|------| +| 起動時間 | 約3.7s短縮(12.6s→8.9s) | +| 並列・非同期化 | 翻訳・音声認識エンジン判定を並列/バックグラウンド化 | +| 安定性 | OSCQuery起動のリトライ上限でブロッキングを抑制 | +| 再利用性 | 重みチェック結果をキャッシュし重複I/Oを削減 | + ## 最近の更新 (2025-10-20) ### 新規ローカルLLM翻訳エンジン統合 @@ -399,25 +419,29 @@ speakerMessage(result: dict) -> None ## エラーハンドリング -### VRAM不足エラー +### エラー構造 +- すべて `VRCTError` で生成し、ステータス・コード・メッセージ・data を統一 +- `create_error_response()` / `create_exception_error_response()` を使用し、`self.run()` へそのまま渡す +- 代表コード: デバイス系 (`DEVICE_NO_MIC` / `DEVICE_NO_SPEAKER`)、VRAM系 (`TRANSLATION_VRAM_*`)、認証系 (`AUTH_*`)、モデル不正 (`MODEL_*`)、バリデーション系 (`VALIDATION_*`)、接続系 (`CONNECTION_LMSTUDIO_FAILED` など) -- 自動的にCTranslate2への切り替え -- ユーザーへの適切な通知 +### VRAM不足エラー +- 翻訳処理中に VRAM 例外を検出し `/run/error_translation_*_vram_overflow` で通知 +- 翻訳機能を自動で無効化し、`TRANSLATION_DISABLED_VRAM` を通知 +- マイク/スピーカー/チャット/有効化時の各パスで専用コードを返却 ### デバイスエラー +- マイク・スピーカー未検出時に `DEVICE_NO_MIC` / `DEVICE_NO_SPEAKER` +- エネルギーしきい値/タイムアウト等のバリデーションに `VALIDATION_*` を使用 -- デバイス接続状態の監視 -- 自動復旧機能 +### 認証・モデルエラー +- DeepL/Plamo/Gemini/OpenAI/Groq/OpenRouter の認証失敗やキー長不正を `AUTH_*` で通知 +- モデル未選択/不正時は `MODEL_*` で通知し、選択リストを再送 -### ネットワークエラー - -- 接続状態の定期確認 -- オフライン機能への切り替え +### 接続エラー +- LMStudio/Ollama 接続失敗を `CONNECTION_*` で通知し、翻訳エンジンリストを更新 ### 設定エラー - -- 設定値の妥当性チェック -- デフォルト値への復帰 +- IP アドレスやしきい値などの不正値を `VALIDATION_*` で統一し、リクエスト値を data に格納 ## パフォーマンス考慮事項 diff --git a/src-python/docs/details/translation_groq.md b/src-python/docs/details/translation_groq.md new file mode 100644 index 00000000..4e4131d7 --- /dev/null +++ b/src-python/docs/details/translation_groq.md @@ -0,0 +1,87 @@ +# translation_groq.py - Groq 翻訳クライアント + +## 概要 + +Groq API を用いた高速 LLM 翻訳クライアントラッパー。OpenAI 互換エンドポイント (`https://api.groq.com/openai/v1`) を利用し、モデル一覧取得・認証・モデル選択・翻訳実行を提供する。 + +## 最近の更新 (2025-12-10) + +- Groq API サポートを新規追加 +- OpenAI 互換エンドポイント経由で高速 LLM 推論を実現 +- 除外キーワード (`whisper`, `embedding`, `image`, `tts`, `audio`, `search`, `transcribe`, `diarize`, `vision`) によるテキスト処理モデルのフィルタリング +- YAML (`prompt/translation_groq.yml`) からシステムプロンプトをロード + +### 影響 + +| 項目 | 内容 | +|------|------| +| 高速化 | Groq の専用ハードウェアによる高速推論 | +| 互換性 | OpenAI 互換 API で既存実装との一貫性維持 | +| 保守性 | OpenAI クライアントと同様の設計で保守容易 | + +## 責務 + +- Groq API Key (`gsk-` で始まる40文字以上) を用いた認証確認 +- 利用可能モデルのフィルタリングとソート +- 選択モデルの検証と内部保持 +- LangChain `ChatOpenAI` インスタンス生成(base_url に Groq エンドポイント指定) +- システムプロンプトによる翻訳実行 + +## 公開API (メソッド) + +```python +class GroqClient: + def __init__(root_path: str = None) + def getModelList() -> list[str] + def getAuthKey() -> str | None + def setAuthKey(api_key: str) -> bool + def getModel() -> str | None + def setModel(model: str) -> bool + def updateClient() -> None + def translate(text: str, input_lang: str, output_lang: str) -> str +``` + +### メソッド詳細 + +- `setAuthKey`: `_authentication_check` に成功した場合のみ内部保存 +- `getModelList`: モデル列挙後フィルタリング適用しソート +- `setModel`: 取得済みリスト内のモデルのみ受理 +- `updateClient`: `ChatOpenAI` を選択モデル・Groq base_url で再生成 +- `translate`: システム + ユーザメッセージ構築→LLM呼び出し→レスポンス正規化 + +## 使用例 + +```python +client = GroqClient() +if client.setAuthKey("gsk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"): + models = client.getModelList() + client.setModel(models[0]) + client.updateClient() + result = client.translate("こんにちは世界", "Japanese", "English") + print(result) +``` + +## 依存関係 + +- `openai.OpenAI`: モデル列挙 / 推論(Groq エンドポイント経由) +- `langchain_openai.ChatOpenAI`: LangChain ラッパー +- `translation_languages.translation_lang`: 対応言語集合 +- `translation_utils.loadPromptConfig`: プロンプト YAML ロード + +## 注意事項 + +- base_url は固定で `https://api.groq.com/openai/v1` +- ストリーミング無効 (streaming=False) 固定 +- API Key 無設定時 `getModelList()` は空 +- API Key は `gsk` で始まり40文字以上であることを検証 + +## 制限事項 + +- エラーメッセージ詳細は包括的に扱わない (上位層でロギング) +- 翻訳結果の構造が複雑 (list/dict) 場合を単純文字列へ normalize するのみ + +## 関連ドキュメント + +- `details/translation_translator.md` +- `details/translation_languages.md` +- `details/translation_openai.md` (類似実装) diff --git a/src-python/docs/details/translation_lmstudio.md b/src-python/docs/details/translation_lmstudio.md index b54d46a8..01516a91 100644 --- a/src-python/docs/details/translation_lmstudio.md +++ b/src-python/docs/details/translation_lmstudio.md @@ -4,6 +4,14 @@ LMStudio 互換 OpenAI API を利用したローカル LLM 翻訳クライアントラッパー。モデル一覧取得・モデル選択・翻訳処理を統一インターフェースで提供する。 +## 最近の更新 (2025-12-30) + +- 接続失敗時のエラーハンドリング改善 + - URL への疎通確認失敗時にモデルリストをクリア (`SELECTABLE_LMSTUDIO_MODEL_LIST = []`) + - 選択モデルをクリア (`SELECTED_LMSTUDIO_MODEL = None`) + - `SELECTABLE_TRANSLATION_ENGINE_STATUS["LMStudio"]` を False に設定 + - フロントエンドに通知して UI を同期 + ## 最近の更新 (2025-10-20) - 新規追加: ローカル LLM (LMStudio) を翻訳エンジン群へ統合 @@ -73,6 +81,10 @@ if models: - `api_key` は固定文字列 "lmstudio" (LMStudio 側で不要のため) を利用 - モデル一覧取得はエンドポイントの互換性に依存 (古いバージョン非対応の可能性) - `updateClient()` 呼び出し前は `translate()` を利用できない +- **接続失敗時の自動処理:** + - URL への疎通確認(接続テスト)が失敗すると、自動的にモデルリストと選択モデルがクリアされる + - `SELECTABLE_TRANSLATION_ENGINE_STATUS["LMStudio"]` が False に設定され、エンジンが使用不可状態になる + - Controller が自動的にフロントエンドに状態変化を通知 ## 制限事項 diff --git a/src-python/docs/details/translation_ollama.md b/src-python/docs/details/translation_ollama.md index d176644e..93d8dc88 100644 --- a/src-python/docs/details/translation_ollama.md +++ b/src-python/docs/details/translation_ollama.md @@ -4,6 +4,14 @@ Ollama サーバー上で稼働するローカル LLM を翻訳エンジンとして扱うためのクライアントラッパー。モデル一覧取得・モデル選択・翻訳実行を統一パターンで提供する。 +## 最近の更新 (2025-12-30) + +- 接続失敗時のエラーハンドリング改善 + - `/api/ping` への疎通確認失敗時にモデルリストをクリア (`SELECTABLE_OLLAMA_MODEL_LIST = []`) + - 選択モデルをクリア (`SELECTED_OLLAMA_MODEL = None`) + - `SELECTABLE_TRANSLATION_ENGINE_STATUS["Ollama"]` を False に設定 + - フロントエンドに通知して UI を同期 + ## 最近の更新 (2025-10-20) - 新規追加: Ollama を翻訳エンジン群へ統合 @@ -73,6 +81,10 @@ if client.authenticationCheck(): - サーバー既定 URL: `http://localhost:11434` - モデル一覧取得は起動しているローカルサーバー状態に依存 - `updateClient()` 呼び出し前は `translate()` を利用不可 +- **接続失敗時の自動処理:** + - `/api/ping` への疎通確認が失敗すると、自動的にモデルリストと選択モデルがクリアされる + - `SELECTABLE_TRANSLATION_ENGINE_STATUS["Ollama"]` が False に設定され、エンジンが使用不可状態になる + - Controller が自動的にフロントエンドに状態変化を通知 ## 制限事項 diff --git a/src-python/docs/details/translation_openrouter.md b/src-python/docs/details/translation_openrouter.md new file mode 100644 index 00000000..3936b19a --- /dev/null +++ b/src-python/docs/details/translation_openrouter.md @@ -0,0 +1,96 @@ +# translation_openrouter.py - OpenRouter 翻訳クライアント + +## 概要 + +OpenRouter API を用いた統合 LLM 翻訳クライアントラッパー。OpenAI 互換エンドポイント (`https://openrouter.ai/api/v1`) を利用し、複数の LLM プロバイダーへの統一アクセスを提供する。 + +## 最近の更新 (2025-12-29) + +- OpenRouter API 認証チェック方法を変更 + - **以前:** `client.models.list()` を呼び出して認証確認 + - **現在:** `https://openrouter.ai/api/v1/auth/key` エンドポイントに GET リクエスト送信して確認 + - **理由:** より信頼性の高い専用認証エンドポイントを使用し、高速かつ確実に API キー有効性を検証 +- 認証失敗時の sensitive data 処理 + - API キー検証失敗時はレスポンス `data` フィールドに `None` を設定(API キーを露出させない) + - エラーメッセージのみを返却し、具体的なキー情報は隠蔽 + +### 影響 + +| 項目 | 内容 | +|------|------| +| 柔軟性 | 複数 LLM プロバイダーを単一インターフェースで利用 | +| 互換性 | OpenAI 互換 API で既存実装との一貫性維持 | +| 拡張性 | 新規モデル追加時も API キー再設定不要 | + +## 責務 + +- OpenRouter API Key (20文字以上) を用いた認証確認 + - `https://openrouter.ai/api/v1/auth/key` エンドポイントへの HTTP GET リクエストで検証(タイムアウト10秒) + - ステータスコード 200 で有効と判定 +- 利用可能モデルのフィルタリングとソート +- 選択モデルの検証と内部保持 +- LangChain `ChatOpenAI` インスタンス生成(base_url に OpenRouter エンドポイント指定) +- システムプロンプトによる翻訳実行 + +## 公開API (メソッド) + +```python +class OpenRouterClient: + def __init__(root_path: str = None) + def getModelList() -> list[str] + def getAuthKey() -> str | None + def setAuthKey(api_key: str) -> bool + def getModel() -> str | None + def setModel(model: str) -> bool + def updateClient() -> None + def translate(text: str, input_lang: str, output_lang: str) -> str +``` + +### メソッド詳細 + +- `setAuthKey`: `_authentication_check` に成功した場合のみ内部保存 +- `getModelList`: モデル列挙後フィルタリング適用しソート +- `setModel`: 取得済みリスト内のモデルのみ受理 +- `updateClient`: `ChatOpenAI` を選択モデル・OpenRouter base_url で再生成 +- `translate`: システム + ユーザメッセージ構築→LLM呼び出し→レスポンス正規化 + +## 使用例 + +```python +client = OpenRouterClient() +if client.setAuthKey("sk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"): + models = client.getModelList() + # OpenRouter は多数のモデルを提供 + client.setModel("anthropic/claude-3-sonnet") + client.updateClient() + result = client.translate("こんにちは世界", "Japanese", "English") + print(result) +``` + +## 依存関係 + +- `openai.OpenAI`: モデル列挙 / 推論(OpenRouter エンドポイント経由) +- `langchain_openai.ChatOpenAI`: LangChain ラッパー +- `translation_languages.translation_lang`: 対応言語集合 +- `translation_utils.loadPromptConfig`: プロンプト YAML ロード + +## 注意事項 + +- base_url は固定で `https://openrouter.ai/api/v1` +- ストリーミング無効 (streaming=False) 固定 +- API Key 無設定時 `getModelList()` は空 +- API Key は20文字以上であることを検証 + +## 制限事項 + +- エラーメッセージ詳細は包括的に扱わない (上位層でロギング) +- 翻訳結果の構造が複雑 (list/dict) 場合を単純文字列へ normalize するのみ +- OpenRouter の料金体系はモデル毎に異なる(利用前に確認が必要) + +## 関連ドキュメント + +- `details/translation_translator.md` +- `details/translation_languages.md` +- `details/translation_openai.md` (類似実装) +- `details/translation_groq.md` (類似実装) + diff --git a/src-python/docs/details/translation_prompt_history.md b/src-python/docs/details/translation_prompt_history.md new file mode 100644 index 00000000..bf1ecd9b --- /dev/null +++ b/src-python/docs/details/translation_prompt_history.md @@ -0,0 +1,186 @@ +# 翻訳プロンプトへの履歴注入(Chat/Mic/Speaker) + +LLM は直前までの会話文脈を理解して翻訳品質を向上させられます。そのため、システムプロンプトに最近の履歴(Chat/Mic/Speaker)を内包する機能を追加しました。大量のログでトークン消費が増えないよう、YAML 設定で取り込み範囲と上限を管理できます。 + +## アーキテクチャ + +### 履歴管理(Model) + +**`model.py`** でChat/Mic/Speakerのメッセージ履歴を一元管理: + +```python +# 履歴バッファ(最大50件) +self.translation_history: list[dict] = [] +self.translation_history_max_items = 50 + +# 履歴追加(オリジナルメッセージのみ、翻訳結果は保存しない) +model.addTranslationHistory("chat", "こんにちは") +model.addTranslationHistory("mic", "今日はいい天気") +model.addTranslationHistory("speaker", "Hello!") + +# 履歴取得 +history = model.getTranslationHistory(max_items=10) + +# 履歴クリア +model.clearTranslationHistory() +``` + +### 自動注入(Model → Translator → 各LLMクライアント) + +- **`model.getTranslate()`** で履歴を取得し、`translator.translate(..., context_history=history)` に渡す。 +- **`Translator.translate()`** 側でエンジンごとの分岐直前に `setContextHistory()` を呼び、履歴をプロンプト組み立てに反映する。 + +```python +# model.getTranslate() +history = self.getTranslationHistory() +translation = self.translator.translate( + translator_name=translator_name, + weight_type=config.CTRANSLATE2_WEIGHT_TYPE, + source_language=source_language, + target_language=target_language, + target_country=target_country, + message=message, + context_history=history, +) + +# Translator.translate() の一例(OpenAI) +case "OpenAI_API": + if self.openai_client is None: + result = False + else: + if context_history: + self.openai_client.setContextHistory(context_history) + result = self.openai_client.translate(message, input_lang=source_language, output_lang=target_language) +``` + +### メッセージ処理(Controller) + +**`controller.py`** で各メッセージ処理完了後に履歴へ追加(翻訳の成否に関係なく、オリジナル文だけ保存): + +- **Chat**: `chatMessage()` の末尾で `model.addTranslationHistory("chat", ...)` +- **Mic**: `micMessage()` の末尾で `model.addTranslationHistory("mic", ...)` +- **Speaker**: `speakerMessage()` の末尾で `model.addTranslationHistory("speaker", ...)` + +## 設定ファイル(例: OpenAI) + +`src-python/models/translation/translation_settings/prompt/translation_openai.yml` + +```yaml +system_prompt: | + You are a helpful translation assistant. + Supported languages: + {supported_languages} + + Translate the user provided text from {input_lang} to {output_lang}. + Return ONLY the translated text. Do not add quotes or extra commentary. +history: + use_history: true # 履歴をプロンプトへ注入するか + sources: [chat, mic, speaker] # 取り込み対象の履歴種別 + max_messages: 10 # 注入する履歴件数の上限(新しい順) + max_chars: 4000 # 履歴整形後の最大文字数(超過時は先頭を切り捨て) + header_template: | + Conversation context (recent {max_messages} messages): + {history} + item_template: "[{timestamp}][{source}] {text}" +``` + +- `system_prompt`: 従来どおり、`{supported_languages}`/`{input_lang}`/`{output_lang}` が利用可能。 +- `history.use_history`: 履歴注入を有効化します。 +- `history.sources`: 取り込み対象ソース。`chat`/`mic`/`speaker` から選択。 +- `history.max_messages`: 新しい順に N 件を取り込みます。 +- `history.max_chars`: 整形後の履歴文字列の最大長。上限を超えた場合は先頭側を切り捨て(新しい文脈を優先)。 +- `history.header_template`: 履歴ヘッダの整形テンプレート。`{max_messages}`/`{history}` が利用可能。 +- `history.item_template`: 各履歴アイテムの整形テンプレート。`{timestamp}`(HH:MM形式)/`{source}`/`{text}` が利用可能。 + +## 実装(OpenAI クライアント) + +`src-python/models/translation/translation_openai.py` + +- `OpenAIClient.setContextHistory(history_items: list[dict])` を追加。 + - `history_items` は以下のキーを含む辞書の配列: + - `source`: `"chat" | "mic" | "speaker"` + - `text`: 文字列 + - `timestamp`: ISO形式の日時文字列(HH:MM形式にフォーマットされてプロンプトに挿入) +- `translate()` 呼び出し時、YAML の `history` 設定に基づき、指定履歴をシステムプロンプト末尾へ整形して注入します。 +- 文字数上限は簡易的に `max_chars` で制御(トークンカウントは行わず、過剰消費抑制用の安全策)。 + +## 使い方 + +### 基本的な流れ + +1. **メッセージ発生時に履歴追加**(controller.py で自動実行) +```python +# Chat送信時(オリジナルメッセージのみ保存) +model.addTranslationHistory("chat", user_message) + +# Mic入力時(音声認識結果のみ保存) +model.addTranslationHistory("mic", transcribed_text) + +# Speaker受信時(受信したオリジナルメッセージのみ保存) +model.addTranslationHistory("speaker", received_text) +``` + +2. **翻訳時に自動注入**(model.py で自動実行) +```python +# getTranslate() 内で自動的に履歴が各LLMクライアントへ注入される +translation = model.getTranslate(translator_name, ...) +``` + +3. **設定の調整**(YAML編集) +```yaml +history: + use_history: true # 有効/無効 + sources: [chat, mic] # chatとmicのみ使う場合 + max_messages: 5 # 最新5件のみ + max_chars: 2000 # 2000文字まで +``` + +### 手動で履歴を操作(必要な場合のみ) + +```python +# 履歴をクリア +model.clearTranslationHistory() + +# 履歴を取得 +recent_history = model.getTranslationHistory(max_items=10) + +# 手動で追加 +model.addTranslationHistory("chat", "カスタムメッセージ") +``` + +## 連携方法(開発者向け) + +既存のcontroller/model統合により、**自動で動作**します: + +1. ユーザーがChat入力 → `controller.chatMessage()` → メッセージ処理完了後に `model.addTranslationHistory()` 呼び出し(翻訳の成功/失敗に関係なく) +2. マイク音声 → `controller.micMessage()` → メッセージ処理完了後に `model.addTranslationHistory()` 呼び出し(翻訳の成功/失敗に関係なく) +3. スピーカー受信 → `controller.speakerMessage()` → メッセージ処理完了後に `model.addTranslationHistory()` 呼び出し(翻訳の成功/失敗に関係なく) +4. 翻訳実行 → `model.getTranslate()` → LLMクライアントへ履歴を自動注入 → `client.translate()` で履歴付きプロンプト生成 + +**重要**: 履歴にはオリジナルメッセージのみが保存されます。翻訳結果は履歴に含まれません。これによりトークン消費を抑え、文脈として必要な情報のみを提供します。 + +**追加実装は不要です。** YAML設定を変更するだけで履歴注入の有効/無効や範囲を制御できます。 + +## 連携方法 + +## 対応状況 + +✅ **全LLMクライアントに展開済み** + +以下のすべてのクライアントで履歴注入機能が利用可能です: +- OpenAI (`translation_openai.py` / `translation_openai.yml`) +- Gemini (`translation_gemini.py` / `translation_gemini.yml`) +- Groq (`translation_groq.py` / `translation_groq.yml`) +- OpenRouter (`translation_openrouter.py` / `translation_openrouter.yml`) +- LMStudio (`translation_lmstudio.py` / `translation_lmstudio.yml`) +- Ollama (`translation_ollama.py` / `translation_ollama.yml`) +- Plamo (`translation_plamo.py` / `translation_plamo.yml`) + +各クライアントで同一の設定形式とAPIインターフェースを使用します: +- `setContextHistory(history_items: list[dict])` メソッド +- YAML の `history` セクション + +## 今後の拡張案 + +- 実トークン見積りに基づく切り詰め(tiktoken 等) +- 要約モデルを使った古い履歴の縮約 diff --git a/src-python/docs/details/translation_translator.md b/src-python/docs/details/translation_translator.md index fd423213..2ed13129 100644 --- a/src-python/docs/details/translation_translator.md +++ b/src-python/docs/details/translation_translator.md @@ -12,6 +12,13 @@ - Microsoft Translator(Bing) - Papago Translator - CTranslate2(ローカル翻訳) +- Plamo API(プリファードネットワークス LLM) +- Gemini API(Google LLM) +- OpenAI API(ChatGPT系) +- Groq API(高速 LLM 推論) +- OpenRouter API(統合 LLM プロバイダー) +- LMStudio(ローカル LLM) +- Ollama(ローカル LLM) ### 統一インターフェース - エンジン依存を隠蔽した単一の翻訳メソッド @@ -405,41 +412,74 @@ root/ - `model.py`: 翻訳機能統合 - `controller.py`: 翻訳制御インターフェース -## 最近の更新 (2025-10-20) +## 最近の更新 -### 新規ローカル LLM エンジン追加 +### 2025-12-10: Groq API および OpenRouter API サポート追加 + +#### Groq API 統合 +- Groq API (`https://api.groq.com/openai/v1`) を翻訳エンジンとして追加 +- OpenAI 互換エンドポイントで高速 LLM 推論を実現 +- API キーバリデーション(`gsk` で始まり40文字以上) +- モデルリスト自動取得とフィルタリング(テキスト処理モデルのみ) +- 認証成功時に `SELECTABLE_GROQ_MODEL_LIST` を更新、未選択時は先頭モデルを自動選択 +- API キー無効時にモデルリストと選択モデルをクリアしフロントエンドに通知 + +#### OpenRouter API 統合 +- OpenRouter API (`https://openrouter.ai/api/v1`) を翻訳エンジンとして追加 +- 単一 API キーで複数 LLM プロバイダーへアクセス可能 +- API キーバリデーション(20文字以上) +- モデルリスト自動取得とフィルタリング(テキスト処理モデルのみ) +- 認証成功時に `SELECTABLE_OPENROUTER_MODEL_LIST` を更新、未選択時は先頭モデルを自動選択 +- API キー無効時にモデルリストと選択モデルをクリアしフロントエンドに通知 + +#### updateTranslationEngineAndEngineList() の拡張 +- `SELECTABLE_TRANSLATION_ENGINE_STATUS` で Groq_API と OpenRouter_API の状態を管理 +- 各エンジンの認証キー更新時に自動的にモデルリストを再取得 +- フロントエンドへの通知を `run_mapping` 経由で送信 + +#### 影響 +| 項目 | 内容 | +|------|------| +| Groq API | 高速 LLM 推論による翻訳速度向上 | +| OpenRouter API | 単一キーで複数 LLM プロバイダーへアクセス | +| モデル管理 | API キー検証失敗時の自動クリアで一貫性向上 | +| UX改善 | 認証後の自動モデル選択で初期設定簡略化 | + +### 2025-10-20: LLM エンジン拡張と最適化 + +#### 新規ローカル LLM エンジン追加 LMStudio / Ollama を翻訳エンジンとして追加。接続確認後にモデルリスト (`SELECTABLE_LMSTUDIO_MODEL_LIST` / `SELECTABLE_OLLAMA_MODEL_LIST`) を取得し、未選択なら先頭モデルを自動選択 (`SELECTED_LMSTUDIO_MODEL` / `SELECTED_OLLAMA_MODEL`)。現時点では CTranslate2 と同様にローカル動作を想定し、翻訳関数側は将来の統合(温度等パラメータ)に備えて抽象化維持。 -### モデル選択プロパティ名称統一 +#### モデル選択プロパティ名称統一 Plamo / Gemini / OpenAI の選択モデルプロパティを `SELECTED_*` 形式へ変更。旧名称 (`PLAMO_MODEL` / `GEMINI_MODEL` / `OPENAI_MODEL`) は利用停止。自動認証後のモデルリスト更新ロジックで未選択時に先頭補完を行う。 -### OpenAI / Gemini / Plamo 認証後のモデルリスト自動更新 +#### OpenAI / Gemini / Plamo 認証後のモデルリスト自動更新 Auth設定メソッド完了時に `SELECTABLE_*_MODEL_LIST` を再取得し不足時は UI へ push。OpenAI はキー設定直後に最新モデルリストを反映し高速化。Gemini / Plamo も同様に `updateTranslator*Client()` 呼び出しでクライアント再生成。 -### CTranslate2 言語ネスト化対応 +#### CTranslate2 言語ネスト化対応 `translation_lang["CTranslate2"][weight_type]["source"|"target"]` へ構造変更。`CTRANSLATE2_WEIGHT_TYPE` により重みタイプ別の言語集合を参照。Translator 内では `translator_name == "CTranslate2"` の分岐で weight_type を参照して言語判定を行う実装に変更。 -### YAML 言語マッピング導入 +#### YAML 言語マッピング導入 外部ファイル `languages.yml` を読み込んで翻訳エンジン別対応言語を動的拡張。新言語追加は YAML 編集のみで実現(コード再デプロイ不要)。読み込み失敗時は空辞書でフォールバックし既存ハードコードを保持。 -### VRAM エラー検知とフォールバック +#### VRAM エラー検知とフォールバック DeepL / Plamo / Gemini / OpenAI 実行時の VRAM 不足検知で自動的に CTranslate2 へ切替し翻訳を停止 (`ENABLE_TRANSLATION=False`)。ユーザー通知後は再度有効化要求時に再初期化を試行。安定性向上のためログへ VRAM エラー詳細を記録。 -### トークナイザーパス修正 +#### トークナイザーパス修正 CTranslate2 トークナイザーのダウンロード処理で保存ディレクトリ作成とパス使用順序不整合を修正。これにより初回起動時の失敗率低下。 -### 全言語ペア包括テスト導入 +#### 全言語ペア包括テスト導入 `backend_test.py` にて `test_translate_all_language_pairs()` を追加。複数エンジン・全言語ペアを列挙実行し `translation_test_results.json` を生成。失敗ペアの早期検出と YAML 追加言語検証に活用。 -### 影響 +#### 影響 | 項目 | 内容 | |------|------| diff --git a/src-python/docs/error_handling_migration_guide.md b/src-python/docs/error_handling_migration_guide.md new file mode 100644 index 00000000..051489f6 --- /dev/null +++ b/src-python/docs/error_handling_migration_guide.md @@ -0,0 +1,212 @@ +# エラーハンドリング統一システム移行ガイド + +## 概要 + +`errors.py`で定義された統一エラーシステムを使用して、すべてのエラーハンドリングを標準化しました。 + +## 変更パターン + +### 1. 基本的なエラーレスポンス + +#### 修正前: +```python +response = { + "status": 400, + "result": { + "message": "Error message", + "data": some_value + } +} +``` + +#### 修正後: +```python +from errors import ErrorCode, VRCTError + +response = VRCTError.create_error_response( + ErrorCode.APPROPRIATE_ERROR_CODE, + data=some_value +) +``` + +### 2. run_mapping経由のエラー通知 + +#### 修正前: +```python +self.run( + 400, + self.run_mapping["error_device"], + { + "message": "No mic device detected", + "data": None + }, +) +``` + +#### 修正後: +```python +error_response = VRCTError.create_error_response( + ErrorCode.DEVICE_NO_MIC, + data=None +) +self.run( + error_response["status"], + self.run_mapping["error_device"], + error_response["result"], +) +``` + +### 3. 例外からのエラー生成 + +#### 修正前: +```python +except Exception as e: + errorLogging() + response = { + "status": 400, + "result": { + "message": f"Error {e}", + "data": original_value + } + } +``` + +#### 修正後: +```python +except Exception as e: + errorLogging() + response = VRCTError.create_exception_error_response( + e, + data=original_value + ) +``` + +## 既に移行済みの箇所 + +### デバイスエラー +- ✅ `progressBarMicEnergy` - `ErrorCode.DEVICE_NO_MIC` +- ✅ `progressBarSpeakerEnergy` - `ErrorCode.DEVICE_NO_SPEAKER` + +### ウェイトダウンロードエラー +- ✅ `DownloadCTranslate2.downloaded` - `ErrorCode.WEIGHT_CTRANSLATE2_DOWNLOAD` +- ✅ `DownloadWhisper.downloaded` - `ErrorCode.WEIGHT_WHISPER_DOWNLOAD` + +### 翻訳エラー +- ✅ `micMessage` - `ErrorCode.TRANSLATION_ENGINE_LIMIT`, `ErrorCode.TRANSLATION_VRAM_MIC`, `ErrorCode.TRANSLATION_DISABLED_VRAM` +- ✅ `speakerMessage` - `ErrorCode.TRANSLATION_ENGINE_LIMIT`, `ErrorCode.TRANSLATION_VRAM_SPEAKER`, `ErrorCode.TRANSLATION_DISABLED_VRAM` +- ✅ `chatMessage` - `ErrorCode.TRANSLATION_ENGINE_LIMIT`, `ErrorCode.TRANSLATION_VRAM_CHAT`, `ErrorCode.TRANSLATION_DISABLED_VRAM` +- ✅ `setEnableTranslation` - `ErrorCode.TRANSLATION_VRAM_ENABLE`, `ErrorCode.TRANSLATION_DISABLED_VRAM` + +### バリデーションエラー +- ✅ `setMicThreshold` - `ErrorCode.VALIDATION_MIC_THRESHOLD` +- ✅ `setSpeakerThreshold` - `ErrorCode.VALIDATION_SPEAKER_THRESHOLD` +- ✅ `setMicRecordTimeout` - `ErrorCode.VALIDATION_MIC_RECORD_TIMEOUT` +- ✅ `setMicPhraseTimeout` - `ErrorCode.VALIDATION_MIC_PHRASE_TIMEOUT` +- ✅ `setMicMaxPhrases` - `ErrorCode.VALIDATION_MIC_MAX_PHRASES` +- ✅ `setSpeakerRecordTimeout` - `ErrorCode.VALIDATION_SPEAKER_RECORD_TIMEOUT` +- ✅ `setSpeakerPhraseTimeout` - `ErrorCode.VALIDATION_SPEAKER_PHRASE_TIMEOUT` +- ✅ `setSpeakerMaxPhrases` - `ErrorCode.VALIDATION_SPEAKER_MAX_PHRASES` +- ✅ `setOscIpAddress` - `ErrorCode.VALIDATION_INVALID_IP`, `ErrorCode.VALIDATION_CANNOT_SET_IP` + +### VRC連携エラー +- ✅ `setEnableVrcMicMuteSync` - `ErrorCode.VRC_MIC_MUTE_SYNC_OSC_DISABLED` + +### 認証エラー +- ✅ `setDeeplAuthKey` - `ErrorCode.AUTH_DEEPL_LENGTH`, `ErrorCode.AUTH_DEEPL_FAILED` + +## 未移行の箇所(要対応) + +以下の箇所は同様のパターンで移行が必要です: + +### 認証関連 +- ⬜ `setPlamoAuthKey` - `ErrorCode.AUTH_PLAMO_LENGTH`, `ErrorCode.AUTH_PLAMO_FAILED` +- ⬜ `setPlamoModel` - `ErrorCode.MODEL_PLAMO_INVALID` +- ⬜ `setGeminiAuthKey` - `ErrorCode.AUTH_GEMINI_LENGTH`, `ErrorCode.AUTH_GEMINI_FAILED` +- ⬜ `setGeminiModel` - `ErrorCode.MODEL_GEMINI_INVALID` +- ⬜ `setOpenAIAuthKey` - `ErrorCode.AUTH_OPENAI_INVALID`, `ErrorCode.AUTH_OPENAI_FAILED` +- ⬜ `setOpenAIModel` - `ErrorCode.MODEL_OPENAI_INVALID` +- ⬜ `setGroqAuthKey` - `ErrorCode.AUTH_GROQ_INVALID`, `ErrorCode.AUTH_GROQ_FAILED` +- ⬜ `setGroqModel` - `ErrorCode.MODEL_GROQ_INVALID` +- ⬜ `setOpenRouterAuthKey` - `ErrorCode.AUTH_OPENROUTER_INVALID`, `ErrorCode.AUTH_OPENROUTER_FAILED` +- ⬜ `setOpenRouterModel` - `ErrorCode.MODEL_OPENROUTER_INVALID` + +### 接続関連 +- ⬜ `checkTranslatorLMStudioConnection` - `ErrorCode.CONNECTION_LMSTUDIO_FAILED` +- ⬜ `setTranslatorLMStudioURL` - `ErrorCode.CONNECTION_LMSTUDIO_URL_INVALID` +- ⬜ `setTranslatorLMStudioModel` - `ErrorCode.MODEL_LMSTUDIO_INVALID` +- ⬜ `checkTranslatorOllamaConnection` - `ErrorCode.CONNECTION_OLLAMA_FAILED` +- ⬜ `setTranslatorOllamaModel` - `ErrorCode.MODEL_OLLAMA_INVALID` + +### WebSocket関連 +- ⬜ `setWebSocketHost` - `ErrorCode.VALIDATION_INVALID_IP`, `ErrorCode.WEBSOCKET_HOST_INVALID` +- ⬜ `setWebSocketPort` - `ErrorCode.WEBSOCKET_PORT_UNAVAILABLE` +- ⬜ `setEnableWebSocketServer` - `ErrorCode.WEBSOCKET_SERVER_UNAVAILABLE` + +### 音声認識VRAM関連 +- ⬜ `startTranscriptionSendMessage` - `ErrorCode.TRANSCRIPTION_VRAM_MIC`, `ErrorCode.TRANSCRIPTION_SEND_DISABLED_VRAM` +- ⬜ `startTranscriptionReceiveMessage` - `ErrorCode.TRANSCRIPTION_VRAM_SPEAKER`, `ErrorCode.TRANSCRIPTION_RECEIVE_DISABLED_VRAM` + +## エラーコードとエンドポイントの対応 + +`errors.py`の`ENDPOINT_ERROR_MAPPING`に、すべてのエンドポイントとエラーコードの対応が定義されています。 +UI開発者はこのマッピングを参照して、各エンドポイントがどのようなエラーを返すか確認できます。 + +## エラーレスポンスの構造 + +統一されたエラーレスポンスは以下の構造を持ちます: + +```python +{ + "status": 400, # HTTPステータスコード + "result": { + "error_code": "ERROR_CODE_CONSTANT", # エラーコード定数 + "message": "Human readable message", # 人間が読めるメッセージ + "data": None or original_value, # エラー時に戻す値(通常は元の値) + "details": {}, # 追加情報(オプション) + "category": "category_name", # エラーカテゴリ + "severity": "warning|error|critical", # 重要度 + } +} +``` + +## UI側での活用 + +UI側では`error_code`を使用して、エラーの種類を判定し、適切な処理を行うことができます: + +```javascript +if (response.status === 400) { + const { error_code, message, data, severity } = response.result; + + switch (error_code) { + case "DEVICE_NO_MIC": + // マイクデバイスエラーの処理 + break; + case "VALIDATION_MIC_THRESHOLD": + // バリデーションエラーの処理(元の値に戻す) + setValue(data); + break; + // ... + } + + // 重要度に応じた表示 + if (severity === "critical") { + showCriticalError(message); + } +} +``` + +## 移行作業の進め方 + +1. **パターンの確認**: 上記の変更パターンを参照 +2. **エラーコードの特定**: `errors.py`から適切な`ErrorCode`を選択 +3. **コードの置き換え**: 古いエラーハンドリングを新しいシステムに置き換え +4. **テスト**: エラーが正しく返されることを確認 +5. **チェックリストの更新**: このドキュメントの✅を更新 + +## 注意事項 + +- すべてのエラーは`errors.py`に定義されたエラーコードを使用すること +- 新しいエラーが必要な場合は、まず`errors.py`に追加すること +- エラーメッセージは`ERROR_METADATA`で定義されたデフォルトメッセージを使用すること + - カスタムメッセージが必要な場合は`custom_message`パラメータを使用 +- `data`パラメータには、エラー時にUIが元の値に戻せるように、元の値を渡すこと diff --git a/src-python/docs/mainloop.md b/src-python/docs/mainloop.md index 8eb3691b..aa0c288c 100644 --- a/src-python/docs/mainloop.md +++ b/src-python/docs/mainloop.md @@ -17,6 +17,10 @@ - `/run/enable_translation` - 翻訳機能の有効/無効状態 - `/run/transcription_mic_message` - マイク音声認識結果 - `/run/transcription_speaker_message` - スピーカー音声認識結果 +- `/run/selectable_groq_model_list` - 利用可能な Groq モデルリスト通知 +- `/run/selected_groq_model` - 選択中の Groq モデル通知 +- `/run/selectable_openrouter_model_list` - 利用可能な OpenRouter モデルリスト通知 +- `/run/selected_openrouter_model` - 選択中の OpenRouter モデル通知 - `/run/error_*` - 各種エラー通知 - `/run/initialization_complete` - 初期化完了通知 @@ -30,7 +34,20 @@ **エンドポイント分類:** - `/get/data/*` - 設定値の取得(初期化時に使用) + - `/get/data/groq_auth_key` - Groq API キー取得 + - `/get/data/selectable_groq_model_list` - 利用可能な Groq モデルリスト取得 + - `/get/data/selected_groq_model` - 選択中の Groq モデル取得 + - `/get/data/openrouter_auth_key` - OpenRouter API キー取得 + - `/get/data/selectable_openrouter_model_list` - 利用可能な OpenRouter モデルリスト取得 + - `/get/data/selected_openrouter_model` - 選択中の OpenRouter モデル取得 - `/set/data/*` - 設定値の更新 + - `/set/data/groq_auth_key` - Groq API キー設定 + - `/set/data/selected_groq_model` - Groq モデル選択 + - `/set/data/openrouter_auth_key` - OpenRouter API キー設定 + - `/set/data/selected_openrouter_model` - OpenRouter モデル選択 +- `/delete/data/*` - 設定値の削除 + - `/delete/data/groq_auth_key` - Groq API キー削除 + - `/delete/data/openrouter_auth_key` - OpenRouter API キー削除 - `/set/enable/*` - 機能の有効化 - `/set/disable/*` - 機能の無効化 - `/run/*` - アクション実行(メッセージ送信、ダウンロード等) diff --git a/src-python/docs/model.md b/src-python/docs/model.md index 41e3ee3f..5795f65a 100644 --- a/src-python/docs/model.md +++ b/src-python/docs/model.md @@ -215,6 +215,76 @@ CTranslate2 モデルがロード済みかチェック。 **戻り値:** 認証成功時 True +--- + +#### Groq API 統合 + +##### `authenticationTranslatorGroqAuthKey(auth_key: str) -> bool` + +**責務:** Groq API キーの検証 + +**処理:** `translator.authenticationGroqAuthKey()` に委譲し、`root_path=config.PATH_LOCAL` を渡す + +**戻り値:** 認証成功時 True + +##### `getTranslatorGroqModelList() -> list[str]` + +**責務:** 利用可能な Groq モデルリストの取得 + +**処理:** `translator.getGroqModelList()` に委譲 + +**戻り値:** モデル名のリスト(例: `["llama3-8b-8192", "mixtral-8x7b-32768"]`) + +##### `setTranslatorGroqModel(model: str) -> bool` + +**責務:** 使用する Groq モデルの設定 + +**処理:** `translator.setGroqModel(model)` に委譲 + +**戻り値:** 設定成功時 True(モデルが利用可能リストに含まれない場合 False) + +##### `updateTranslatorGroqClient() -> None` + +**責務:** Groq クライアントの更新(モデル変更後に呼び出し) + +**処理:** `translator.updateGroqClient()` に委譲し、新しいモデルで LangChain `ChatOpenAI` インスタンスを再生成 + +--- + +#### OpenRouter API 統合 + +##### `authenticationTranslatorOpenRouterAuthKey(auth_key: str) -> bool` + +**責務:** OpenRouter API キーの検証 + +**処理:** `translator.authenticationOpenRouterAuthKey()` に委譲し、`root_path=config.PATH_LOCAL` を渡す + +**戻り値:** 認証成功時 True + +##### `getTranslatorOpenRouterModelList() -> list[str]` + +**責務:** 利用可能な OpenRouter モデルリストの取得 + +**処理:** `translator.getOpenRouterModelList()` に委譲 + +**戻り値:** モデル名のリスト(例: `["anthropic/claude-3-sonnet", "google/gemini-pro"]`) + +##### `setTranslatorOpenRouterModel(model: str) -> bool` + +**責務:** 使用する OpenRouter モデルの設定 + +**処理:** `translator.setOpenRouterModel(model)` に委譲 + +**戻り値:** 設定成功時 True(モデルが利用可能リストに含まれない場合 False) + +##### `updateTranslatorOpenRouterClient() -> None` + +**責務:** OpenRouter クライアントの更新(モデル変更後に呼び出し) + +**処理:** `translator.updateOpenRouterClient()` に委譲し、新しいモデルで LangChain `ChatOpenAI` インスタンスを再生成 + +--- + #### 翻訳実行 ##### `getTranslate(translator_name, source_language, target_language, target_country, message) -> Tuple[str, bool]` diff --git a/src-python/errors.py b/src-python/errors.py new file mode 100644 index 00000000..abe75628 --- /dev/null +++ b/src-python/errors.py @@ -0,0 +1,694 @@ +# src-python/errors.py +""" +統一エラー管理システム + +すべてのエラーを一元管理し、エンドポイントとエラーコードの対応を明確にする。 +""" + +from typing import Any, Optional, Dict +from enum import Enum + + +class ErrorCode(str, Enum): + """エラーコード定数 + + 命名規則: カテゴリ_具体的な内容 + """ + # ============================================================================ + # デバイス関連エラー (DEVICE_*) + # ============================================================================ + DEVICE_NO_MIC = "DEVICE_NO_MIC" + DEVICE_NO_SPEAKER = "DEVICE_NO_SPEAKER" + + # ============================================================================ + # 翻訳関連エラー (TRANSLATION_*) + # ============================================================================ + TRANSLATION_ENGINE_LIMIT = "TRANSLATION_ENGINE_LIMIT" + TRANSLATION_VRAM_CHAT = "TRANSLATION_VRAM_CHAT" + TRANSLATION_VRAM_MIC = "TRANSLATION_VRAM_MIC" + TRANSLATION_VRAM_SPEAKER = "TRANSLATION_VRAM_SPEAKER" + TRANSLATION_VRAM_ENABLE = "TRANSLATION_VRAM_ENABLE" + TRANSLATION_DISABLED_VRAM = "TRANSLATION_DISABLED_VRAM" + + # ============================================================================ + # 音声認識関連エラー (TRANSCRIPTION_*) + # ============================================================================ + TRANSCRIPTION_VRAM_MIC = "TRANSCRIPTION_VRAM_MIC" + TRANSCRIPTION_VRAM_SPEAKER = "TRANSCRIPTION_VRAM_SPEAKER" + TRANSCRIPTION_SEND_DISABLED_VRAM = "TRANSCRIPTION_SEND_DISABLED_VRAM" + TRANSCRIPTION_RECEIVE_DISABLED_VRAM = "TRANSCRIPTION_RECEIVE_DISABLED_VRAM" + + # ============================================================================ + # ウェイトダウンロード関連エラー (WEIGHT_*) + # ============================================================================ + WEIGHT_CTRANSLATE2_DOWNLOAD = "WEIGHT_CTRANSLATE2_DOWNLOAD" + WEIGHT_WHISPER_DOWNLOAD = "WEIGHT_WHISPER_DOWNLOAD" + + # ============================================================================ + # バリデーションエラー (VALIDATION_*) + # ============================================================================ + VALIDATION_MIC_THRESHOLD = "VALIDATION_MIC_THRESHOLD" + VALIDATION_SPEAKER_THRESHOLD = "VALIDATION_SPEAKER_THRESHOLD" + VALIDATION_MIC_RECORD_TIMEOUT = "VALIDATION_MIC_RECORD_TIMEOUT" + VALIDATION_MIC_PHRASE_TIMEOUT = "VALIDATION_MIC_PHRASE_TIMEOUT" + VALIDATION_MIC_MAX_PHRASES = "VALIDATION_MIC_MAX_PHRASES" + VALIDATION_SPEAKER_RECORD_TIMEOUT = "VALIDATION_SPEAKER_RECORD_TIMEOUT" + VALIDATION_SPEAKER_PHRASE_TIMEOUT = "VALIDATION_SPEAKER_PHRASE_TIMEOUT" + VALIDATION_SPEAKER_MAX_PHRASES = "VALIDATION_SPEAKER_MAX_PHRASES" + VALIDATION_INVALID_IP = "VALIDATION_INVALID_IP" + VALIDATION_CANNOT_SET_IP = "VALIDATION_CANNOT_SET_IP" + + # ============================================================================ + # 認証エラー (AUTH_*) + # ============================================================================ + AUTH_DEEPL_LENGTH = "AUTH_DEEPL_LENGTH" + AUTH_DEEPL_FAILED = "AUTH_DEEPL_FAILED" + AUTH_PLAMO_LENGTH = "AUTH_PLAMO_LENGTH" + AUTH_PLAMO_FAILED = "AUTH_PLAMO_FAILED" + AUTH_GEMINI_LENGTH = "AUTH_GEMINI_LENGTH" + AUTH_GEMINI_FAILED = "AUTH_GEMINI_FAILED" + AUTH_OPENAI_INVALID = "AUTH_OPENAI_INVALID" + AUTH_OPENAI_FAILED = "AUTH_OPENAI_FAILED" + AUTH_GROQ_INVALID = "AUTH_GROQ_INVALID" + AUTH_GROQ_FAILED = "AUTH_GROQ_FAILED" + AUTH_OPENROUTER_INVALID = "AUTH_OPENROUTER_INVALID" + AUTH_OPENROUTER_FAILED = "AUTH_OPENROUTER_FAILED" + + # ============================================================================ + # モデル選択エラー (MODEL_*) + # ============================================================================ + MODEL_PLAMO_INVALID = "MODEL_PLAMO_INVALID" + MODEL_GEMINI_INVALID = "MODEL_GEMINI_INVALID" + MODEL_OPENAI_INVALID = "MODEL_OPENAI_INVALID" + MODEL_GROQ_INVALID = "MODEL_GROQ_INVALID" + MODEL_OPENROUTER_INVALID = "MODEL_OPENROUTER_INVALID" + MODEL_LMSTUDIO_INVALID = "MODEL_LMSTUDIO_INVALID" + MODEL_OLLAMA_INVALID = "MODEL_OLLAMA_INVALID" + + # ============================================================================ + # 接続エラー (CONNECTION_*) + # ============================================================================ + CONNECTION_LMSTUDIO_FAILED = "CONNECTION_LMSTUDIO_FAILED" + CONNECTION_OLLAMA_FAILED = "CONNECTION_OLLAMA_FAILED" + CONNECTION_LMSTUDIO_URL_INVALID = "CONNECTION_LMSTUDIO_URL_INVALID" + + # ============================================================================ + # WebSocketエラー (WEBSOCKET_*) + # ============================================================================ + WEBSOCKET_HOST_INVALID = "WEBSOCKET_HOST_INVALID" + WEBSOCKET_PORT_UNAVAILABLE = "WEBSOCKET_PORT_UNAVAILABLE" + WEBSOCKET_SERVER_UNAVAILABLE = "WEBSOCKET_SERVER_UNAVAILABLE" + + # ============================================================================ + # VRC連携エラー (VRC_*) + # ============================================================================ + VRC_MIC_MUTE_SYNC_OSC_DISABLED = "VRC_MIC_MUTE_SYNC_OSC_DISABLED" + + # ============================================================================ + # 汎用エラー (GENERAL_*) + # ============================================================================ + GENERAL_EXCEPTION = "GENERAL_EXCEPTION" + GENERAL_UNKNOWN = "GENERAL_UNKNOWN" + + +class ErrorCategory(str, Enum): + """エラーカテゴリ""" + DEVICE = "device" + TRANSLATION = "translation" + TRANSCRIPTION = "transcription" + WEIGHT = "weight" + VALIDATION = "validation" + AUTH = "auth" + MODEL = "model" + CONNECTION = "connection" + WEBSOCKET = "websocket" + VRC = "vrc" + GENERAL = "general" + + +# エラーコードのメタデータ定義 +ERROR_METADATA: Dict[ErrorCode, Dict[str, Any]] = { + # デバイスエラー + ErrorCode.DEVICE_NO_MIC: { + "category": ErrorCategory.DEVICE, + "message": "No mic device detected", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.DEVICE_NO_SPEAKER: { + "category": ErrorCategory.DEVICE, + "message": "No speaker device detected", + "severity": "error", + "user_action_required": True, + }, + + # 翻訳エラー + ErrorCode.TRANSLATION_ENGINE_LIMIT: { + "category": ErrorCategory.TRANSLATION, + "message": "Translation engine limit error", + "severity": "warning", + "user_action_required": False, + "auto_fallback": True, + }, + ErrorCode.TRANSLATION_VRAM_CHAT: { + "category": ErrorCategory.TRANSLATION, + "message": "VRAM out of memory during translation of chat", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSLATION_VRAM_MIC: { + "category": ErrorCategory.TRANSLATION, + "message": "VRAM out of memory during translation of mic", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSLATION_VRAM_SPEAKER: { + "category": ErrorCategory.TRANSLATION, + "message": "VRAM out of memory during translation of speaker", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSLATION_VRAM_ENABLE: { + "category": ErrorCategory.TRANSLATION, + "message": "VRAM out of memory enabling translation", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSLATION_DISABLED_VRAM: { + "category": ErrorCategory.TRANSLATION, + "message": "Translation disabled due to VRAM overflow", + "severity": "critical", + "user_action_required": True, + }, + + # 音声認識エラー + ErrorCode.TRANSCRIPTION_VRAM_MIC: { + "category": ErrorCategory.TRANSCRIPTION, + "message": "VRAM out of memory during mic transcription", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSCRIPTION_VRAM_SPEAKER: { + "category": ErrorCategory.TRANSCRIPTION, + "message": "VRAM out of memory during speaker transcription", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSCRIPTION_SEND_DISABLED_VRAM: { + "category": ErrorCategory.TRANSCRIPTION, + "message": "Transcription send disabled due to VRAM overflow", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSCRIPTION_RECEIVE_DISABLED_VRAM: { + "category": ErrorCategory.TRANSCRIPTION, + "message": "Transcription receive disabled due to VRAM overflow", + "severity": "critical", + "user_action_required": True, + }, + + # ウェイトダウンロードエラー + ErrorCode.WEIGHT_CTRANSLATE2_DOWNLOAD: { + "category": ErrorCategory.WEIGHT, + "message": "CTranslate2 weight download error", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.WEIGHT_WHISPER_DOWNLOAD: { + "category": ErrorCategory.WEIGHT, + "message": "Whisper weight download error", + "severity": "error", + "user_action_required": True, + }, + + # バリデーションエラー + ErrorCode.VALIDATION_MIC_THRESHOLD: { + "category": ErrorCategory.VALIDATION, + "message": "Mic energy threshold value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_SPEAKER_THRESHOLD: { + "category": ErrorCategory.VALIDATION, + "message": "Speaker energy threshold value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_MIC_RECORD_TIMEOUT: { + "category": ErrorCategory.VALIDATION, + "message": "Mic record timeout value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_MIC_PHRASE_TIMEOUT: { + "category": ErrorCategory.VALIDATION, + "message": "Mic phrase timeout value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_MIC_MAX_PHRASES: { + "category": ErrorCategory.VALIDATION, + "message": "Mic max phrases value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_SPEAKER_RECORD_TIMEOUT: { + "category": ErrorCategory.VALIDATION, + "message": "Speaker record timeout value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_SPEAKER_PHRASE_TIMEOUT: { + "category": ErrorCategory.VALIDATION, + "message": "Speaker phrase timeout value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_SPEAKER_MAX_PHRASES: { + "category": ErrorCategory.VALIDATION, + "message": "Speaker max phrases value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_INVALID_IP: { + "category": ErrorCategory.VALIDATION, + "message": "Invalid IP address", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_CANNOT_SET_IP: { + "category": ErrorCategory.VALIDATION, + "message": "Cannot set IP address", + "severity": "error", + "user_action_required": True, + }, + + # 認証エラー + ErrorCode.AUTH_DEEPL_LENGTH: { + "category": ErrorCategory.AUTH, + "message": "DeepL auth key length is not correct", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_DEEPL_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of deepL auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_PLAMO_LENGTH: { + "category": ErrorCategory.AUTH, + "message": "Plamo auth key length is not correct", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_PLAMO_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of plamo auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_GEMINI_LENGTH: { + "category": ErrorCategory.AUTH, + "message": "Gemini auth key length is not correct", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_GEMINI_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of gemini auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_OPENAI_INVALID: { + "category": ErrorCategory.AUTH, + "message": "OpenAI auth key is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_OPENAI_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of OpenAI auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_GROQ_INVALID: { + "category": ErrorCategory.AUTH, + "message": "Groq auth key is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_GROQ_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of Groq auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_OPENROUTER_INVALID: { + "category": ErrorCategory.AUTH, + "message": "OpenRouter auth key is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_OPENROUTER_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of OpenRouter auth key", + "severity": "error", + "user_action_required": True, + }, + + # モデル選択エラー + ErrorCode.MODEL_PLAMO_INVALID: { + "category": ErrorCategory.MODEL, + "message": "Plamo model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_GEMINI_INVALID: { + "category": ErrorCategory.MODEL, + "message": "Gemini model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_OPENAI_INVALID: { + "category": ErrorCategory.MODEL, + "message": "OpenAI model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_GROQ_INVALID: { + "category": ErrorCategory.MODEL, + "message": "Groq model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_OPENROUTER_INVALID: { + "category": ErrorCategory.MODEL, + "message": "OpenRouter model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_LMSTUDIO_INVALID: { + "category": ErrorCategory.MODEL, + "message": "LMStudio model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_OLLAMA_INVALID: { + "category": ErrorCategory.MODEL, + "message": "ollama model is not valid", + "severity": "warning", + "user_action_required": True, + }, + + # 接続エラー + ErrorCode.CONNECTION_LMSTUDIO_FAILED: { + "category": ErrorCategory.CONNECTION, + "message": "Cannot connect to LMStudio server", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.CONNECTION_OLLAMA_FAILED: { + "category": ErrorCategory.CONNECTION, + "message": "Cannot connect to ollama server", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.CONNECTION_LMSTUDIO_URL_INVALID: { + "category": ErrorCategory.CONNECTION, + "message": "LMStudio URL is not valid", + "severity": "warning", + "user_action_required": True, + }, + + # WebSocketエラー + ErrorCode.WEBSOCKET_HOST_INVALID: { + "category": ErrorCategory.WEBSOCKET, + "message": "WebSocket server host is not available", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.WEBSOCKET_PORT_UNAVAILABLE: { + "category": ErrorCategory.WEBSOCKET, + "message": "WebSocket server port is not available", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.WEBSOCKET_SERVER_UNAVAILABLE: { + "category": ErrorCategory.WEBSOCKET, + "message": "WebSocket server host or port is not available", + "severity": "error", + "user_action_required": True, + }, + + # VRC連携エラー + ErrorCode.VRC_MIC_MUTE_SYNC_OSC_DISABLED: { + "category": ErrorCategory.VRC, + "message": "Cannot enable VRC mic mute sync while OSC query is disabled", + "severity": "warning", + "user_action_required": True, + }, + + # 汎用エラー + ErrorCode.GENERAL_EXCEPTION: { + "category": ErrorCategory.GENERAL, + "message": "An error occurred", + "severity": "error", + "user_action_required": False, + }, + ErrorCode.GENERAL_UNKNOWN: { + "category": ErrorCategory.GENERAL, + "message": "Unknown error", + "severity": "error", + "user_action_required": False, + }, +} + + +class VRCTError: + """VRCTエラーハンドリングクラス""" + + @staticmethod + def create_error_response( + error_code: ErrorCode, + data: Any = None, + details: Optional[Dict[str, Any]] = None, + custom_message: Optional[str] = None + ) -> Dict[str, Any]: + """統一されたエラーレスポンスを生成 + + Args: + error_code: エラーコード + data: エラー時に戻す値(通常は元の値) + details: 追加の詳細情報 + custom_message: カスタムメッセージ(指定しない場合はデフォルトメッセージ) + + Returns: + エラーレスポンス辞書 + """ + metadata = ERROR_METADATA.get(error_code, ERROR_METADATA[ErrorCode.GENERAL_UNKNOWN]) + + return { + "status": 400, + "result": { + "error_code": error_code.value, + "message": custom_message or metadata["message"], + "data": data, + "details": details or {}, + "category": metadata["category"].value, + "severity": metadata["severity"], + } + } + + @staticmethod + def create_exception_error_response( + exception: Exception, + data: Any = None, + error_code: ErrorCode = ErrorCode.GENERAL_EXCEPTION + ) -> Dict[str, Any]: + """例外からエラーレスポンスを生成 + + Args: + exception: 発生した例外 + data: エラー時に戻す値 + error_code: エラーコード + + Returns: + エラーレスポンス辞書 + """ + return VRCTError.create_error_response( + error_code=error_code, + data=data, + custom_message=f"Error: {str(exception)}", + details={"exception_type": type(exception).__name__} + ) + + +# エンドポイントとエラーコードのマッピング +# UIがエラーハンドリングする際の参照として使用 +ENDPOINT_ERROR_MAPPING: Dict[str, Dict[str, ErrorCode]] = { + # run_mapping経由のエラー通知 + "/run/error_device": { + "NO_MIC": ErrorCode.DEVICE_NO_MIC, + "NO_SPEAKER": ErrorCode.DEVICE_NO_SPEAKER, + }, + "/run/error_translation_engine": { + "LIMIT": ErrorCode.TRANSLATION_ENGINE_LIMIT, + }, + "/run/error_translation_chat_vram_overflow": { + "VRAM": ErrorCode.TRANSLATION_VRAM_CHAT, + }, + "/run/error_translation_mic_vram_overflow": { + "VRAM": ErrorCode.TRANSLATION_VRAM_MIC, + }, + "/run/error_translation_speaker_vram_overflow": { + "VRAM": ErrorCode.TRANSLATION_VRAM_SPEAKER, + }, + "/run/error_transcription_mic_vram_overflow": { + "VRAM": ErrorCode.TRANSCRIPTION_VRAM_MIC, + }, + "/run/error_transcription_speaker_vram_overflow": { + "VRAM": ErrorCode.TRANSCRIPTION_VRAM_SPEAKER, + }, + "/run/error_ctranslate2_weight": { + "DOWNLOAD": ErrorCode.WEIGHT_CTRANSLATE2_DOWNLOAD, + }, + "/run/error_whisper_weight": { + "DOWNLOAD": ErrorCode.WEIGHT_WHISPER_DOWNLOAD, + }, + + # エンドポイント直接のエラーレスポンス + "/set/data/mic_threshold": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_MIC_THRESHOLD, + }, + "/set/data/speaker_threshold": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_SPEAKER_THRESHOLD, + }, + "/set/data/mic_record_timeout": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_MIC_RECORD_TIMEOUT, + }, + "/set/data/mic_phrase_timeout": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_MIC_PHRASE_TIMEOUT, + }, + "/set/data/mic_max_phrases": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_MIC_MAX_PHRASES, + }, + "/set/data/speaker_record_timeout": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_SPEAKER_RECORD_TIMEOUT, + }, + "/set/data/speaker_phrase_timeout": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_SPEAKER_PHRASE_TIMEOUT, + }, + "/set/data/speaker_max_phrases": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_SPEAKER_MAX_PHRASES, + }, + "/set/data/osc_ip_address": { + "INVALID": ErrorCode.VALIDATION_INVALID_IP, + "CANNOT_SET": ErrorCode.VALIDATION_CANNOT_SET_IP, + }, + "/set/data/deepl_auth_key": { + "LENGTH": ErrorCode.AUTH_DEEPL_LENGTH, + "FAILED": ErrorCode.AUTH_DEEPL_FAILED, + }, + "/set/data/plamo_auth_key": { + "LENGTH": ErrorCode.AUTH_PLAMO_LENGTH, + "FAILED": ErrorCode.AUTH_PLAMO_FAILED, + }, + "/set/data/selected_plamo_model": { + "INVALID": ErrorCode.MODEL_PLAMO_INVALID, + }, + "/set/data/gemini_auth_key": { + "LENGTH": ErrorCode.AUTH_GEMINI_LENGTH, + "FAILED": ErrorCode.AUTH_GEMINI_FAILED, + }, + "/set/data/selected_gemini_model": { + "INVALID": ErrorCode.MODEL_GEMINI_INVALID, + }, + "/set/data/openai_auth_key": { + "INVALID": ErrorCode.AUTH_OPENAI_INVALID, + "FAILED": ErrorCode.AUTH_OPENAI_FAILED, + }, + "/set/data/selected_openai_model": { + "INVALID": ErrorCode.MODEL_OPENAI_INVALID, + }, + "/set/data/groq_auth_key": { + "INVALID": ErrorCode.AUTH_GROQ_INVALID, + "FAILED": ErrorCode.AUTH_GROQ_FAILED, + }, + "/set/data/selected_groq_model": { + "INVALID": ErrorCode.MODEL_GROQ_INVALID, + }, + "/set/data/openrouter_auth_key": { + "INVALID": ErrorCode.AUTH_OPENROUTER_INVALID, + "FAILED": ErrorCode.AUTH_OPENROUTER_FAILED, + }, + "/set/data/selected_openrouter_model": { + "INVALID": ErrorCode.MODEL_OPENROUTER_INVALID, + }, + "/run/lmstudio_connection": { + "FAILED": ErrorCode.CONNECTION_LMSTUDIO_FAILED, + }, + "/set/data/lmstudio_url": { + "INVALID": ErrorCode.CONNECTION_LMSTUDIO_URL_INVALID, + }, + "/set/data/selected_lmstudio_model": { + "INVALID": ErrorCode.MODEL_LMSTUDIO_INVALID, + }, + "/run/ollama_connection": { + "FAILED": ErrorCode.CONNECTION_OLLAMA_FAILED, + }, + "/set/data/selected_ollama_model": { + "INVALID": ErrorCode.MODEL_OLLAMA_INVALID, + }, + "/set/data/websocket_host": { + "INVALID_IP": ErrorCode.VALIDATION_INVALID_IP, + "UNAVAILABLE": ErrorCode.WEBSOCKET_HOST_INVALID, + }, + "/set/data/websocket_port": { + "UNAVAILABLE": ErrorCode.WEBSOCKET_PORT_UNAVAILABLE, + }, + "/set/enable/websocket_server": { + "UNAVAILABLE": ErrorCode.WEBSOCKET_SERVER_UNAVAILABLE, + }, + "/set/enable/vrc_mic_mute_sync": { + "OSC_DISABLED": ErrorCode.VRC_MIC_MUTE_SYNC_OSC_DISABLED, + }, +} + + +def get_error_metadata(error_code: ErrorCode) -> Dict[str, Any]: + """エラーコードのメタデータを取得 + + Args: + error_code: エラーコード + + Returns: + メタデータ辞書 + """ + return ERROR_METADATA.get(error_code, ERROR_METADATA[ErrorCode.GENERAL_UNKNOWN]) + + +def is_critical_error(error_code: ErrorCode) -> bool: + """クリティカルエラーかどうかを判定 + + Args: + error_code: エラーコード + + Returns: + クリティカルエラーの場合True + """ + metadata = get_error_metadata(error_code) + return metadata.get("severity") == "critical" + + +def requires_user_action(error_code: ErrorCode) -> bool: + """ユーザーアクションが必要なエラーかどうかを判定 + + Args: + error_code: エラーコード + + Returns: + ユーザーアクションが必要な場合True + """ + metadata = get_error_metadata(error_code) + return metadata.get("user_action_required", False) diff --git a/src-python/mainloop.py b/src-python/mainloop.py index 6fa65fc9..f922a1f1 100644 --- a/src-python/mainloop.py +++ b/src-python/mainloop.py @@ -58,6 +58,10 @@ run_mapping = { "selected_gemini_model":"/run/selected_gemini_model", "selectable_openai_model_list":"/run/selectable_openai_model_list", "selected_openai_model":"/run/selected_openai_model", + "selectable_groq_model_list":"/run/selectable_groq_model_list", + "selected_groq_model":"/run/selected_groq_model", + "selectable_openrouter_model_list":"/run/selectable_openrouter_model_list", + "selected_openrouter_model":"/run/selected_openrouter_model", "selectable_lmstudio_model_list":"/run/selectable_lmstudio_model_list", "selected_lmstudio_model":"/run/selected_lmstudio_model", "selectable_ollama_model_list":"/run/selectable_ollama_model_list", @@ -207,6 +211,21 @@ mapping = { "/set/data/openai_auth_key": {"status": True, "variable":controller.setOpenAIAuthKey}, "/delete/data/openai_auth_key": {"status": True, "variable":controller.delOpenAIAuthKey}, + "/get/data/selectable_groq_model_list": {"status": True, "variable":controller.getGroqModelList}, + "/get/data/selected_groq_model": {"status": True, "variable":controller.getGroqModel}, + "/set/data/selected_groq_model": {"status": True, "variable":controller.setGroqModel}, + "/get/data/groq_auth_key": {"status": True, "variable":controller.getGroqAuthKey}, + "/set/data/groq_auth_key": {"status": True, "variable":controller.setGroqAuthKey}, + "/delete/data/groq_auth_key": {"status": True, "variable":controller.delGroqAuthKey}, + + "/get/data/selectable_openrouter_model_list": {"status": True, "variable":controller.getOpenRouterModelList}, + "/get/data/selected_openrouter_model": {"status": True, "variable":controller.getOpenRouterModel}, + "/set/data/selected_openrouter_model": {"status": True, "variable":controller.setOpenRouterModel}, + "/get/data/openrouter_auth_key": {"status": True, "variable":controller.getOpenRouterAuthKey}, + "/set/data/openrouter_auth_key": {"status": True, "variable":controller.setOpenRouterAuthKey}, + "/delete/data/openrouter_auth_key": {"status": True, "variable":controller.delOpenRouterAuthKey}, + + "/get/data/connected_lmstudio": {"status": True, "variable":controller.getTranslatorLMStudioConnection}, "/run/lmstudio_connection": {"status": True, "variable":controller.checkTranslatorLMStudioConnection}, "/get/data/selectable_lmstudio_model_list": {"status": True, "variable":controller.getTranslatorLStudioModelList}, "/get/data/selected_lmstudio_model": {"status": True, "variable":controller.getTranslatorLMStudioModel}, @@ -214,6 +233,7 @@ mapping = { "/get/data/lmstudio_url": {"status": True, "variable":controller.getTranslatorLMStudioURL}, "/set/data/lmstudio_url": {"status": True, "variable":controller.setTranslatorLMStudioURL}, + "/get/data/connected_ollama": {"status": True, "variable":controller.getTranslatorOllamaConnection}, "/run/ollama_connection": {"status": True, "variable":controller.checkTranslatorOllamaConnection}, "/get/data/selectable_ollama_model_list": {"status": True, "variable":controller.getTranslatorOllamaModelList}, "/get/data/selected_ollama_model": {"status": True, "variable":controller.getTranslatorOllamaModel}, diff --git a/src-python/model.py b/src-python/model.py index 1a74ee62..a73687e8 100644 --- a/src-python/model.py +++ b/src-python/model.py @@ -118,6 +118,8 @@ class Model: self.previous_receive_message = "" self.translator = Translator() self.keyword_processor = KeywordProcessor() + self.translation_history: list[dict] = [] + self.translation_history_max_items = 20 overlay_small_log_settings = copy.deepcopy(config.OVERLAY_SMALL_LOG_SETTINGS) overlay_large_log_settings = copy.deepcopy(config.OVERLAY_LARGE_LOG_SETTINGS) overlay_large_log_settings["ui_scaling"] = overlay_large_log_settings["ui_scaling"] * 0.25 @@ -251,6 +253,44 @@ class Model: self.ensure_initialized() self.translator.updateOpenAIClient() + def authenticationTranslatorGroqAuthKey(self, auth_key: str) -> bool: + result = self.translator.authenticationGroqAuthKey(auth_key, root_path=config.PATH_LOCAL) + return result + + def getTranslatorGroqModelList(self) -> list[str]: + self.ensure_initialized() + return self.translator.getGroqModelList() + + def setTranslatorGroqModel(self, model: str) -> bool: + self.ensure_initialized() + result = self.translator.setGroqModel(model=model) + return result + + def updateTranslatorGroqClient(self) -> None: + self.ensure_initialized() + self.translator.updateGroqClient() + + def authenticationTranslatorOpenRouterAuthKey(self, auth_key: str) -> bool: + result = self.translator.authenticationOpenRouterAuthKey(auth_key, root_path=config.PATH_LOCAL) + return result + + def getTranslatorOpenRouterModelList(self) -> list[str]: + self.ensure_initialized() + return self.translator.getOpenRouterModelList() + + def setTranslatorOpenRouterModel(self, model: str) -> bool: + self.ensure_initialized() + result = self.translator.setOpenRouterModel(model=model) + return result + + def updateTranslatorOpenRouterClient(self) -> None: + self.ensure_initialized() + self.translator.updateOpenRouterClient() + + def getTranslatorLMStudioConnected(self) -> bool: + self.ensure_initialized() + return self.translator.getLMStudioConnected() + def authenticationTranslatorLMStudio(self, base_url: str) -> bool: result = self.translator.setLMStudioClientURL(base_url=base_url, root_path=config.PATH_LOCAL) return result @@ -267,6 +307,10 @@ class Model: self.ensure_initialized() self.translator.updateLMStudioClient() + def getTranslatorOllamaConnected(self) -> bool: + self.ensure_initialized() + return self.translator.getOllamaConnected() + def authenticationTranslatorOllama(self) -> bool: result = self.translator.checkOllamaClient(root_path=config.PATH_LOCAL) return result @@ -338,16 +382,62 @@ class Model: return compatible_engines + def addTranslationHistory(self, source: str, text: str) -> None: + """Add a message to translation context history. + + Args: + source: "chat" | "mic" | "speaker" + text: message content + """ + self.ensure_initialized() + if not text or not text.strip(): + return + + history_item = { + "source": source, + "text": text.strip(), + "timestamp": datetime.now().isoformat(), + } + self.translation_history.append(history_item) + + # 最大件数を超えた場合は古いものを削除 + if len(self.translation_history) > self.translation_history_max_items: + self.translation_history = self.translation_history[-self.translation_history_max_items:] + + def getTranslationHistory(self, max_items: int = None) -> list[dict]: + """Get recent translation context history. + + Args: + max_items: Maximum number of items to return (newest first) + + Returns: + List of history items + """ + self.ensure_initialized() + if max_items is None or max_items <= 0: + return self.translation_history + return self.translation_history[-max_items:] + + def clearTranslationHistory(self) -> None: + """Clear all translation context history.""" + self.ensure_initialized() + self.translation_history = [] + def getTranslate(self, translator_name, source_language, target_language, target_country, message): self.ensure_initialized() success_flag = False + + # Get context history for LLM-based translators + history = self.getTranslationHistory() + translation = self.translator.translate( translator_name=translator_name, weight_type=config.CTRANSLATE2_WEIGHT_TYPE, source_language=source_language, target_language=target_language, target_country=target_country, - message=message + message=message, + context_history=history ) # 翻訳失敗時のフェールセーフ処理 diff --git a/src-python/models/translation/prompt/translation_gemini.yml b/src-python/models/translation/prompt/translation_gemini.yml deleted file mode 100644 index 8dbe4927..00000000 --- a/src-python/models/translation/prompt/translation_gemini.yml +++ /dev/null @@ -1,7 +0,0 @@ -system_prompt: | - You are a helpful translation assistant. - Supported languages: - {supported_languages} - - Translate the user provided text from {input_lang} to {output_lang}. - Return ONLY the translated text. Do not add quotes or extra commentary. \ No newline at end of file diff --git a/src-python/models/translation/prompt/translation_lmstudio.yml b/src-python/models/translation/prompt/translation_lmstudio.yml deleted file mode 100644 index 8dbe4927..00000000 --- a/src-python/models/translation/prompt/translation_lmstudio.yml +++ /dev/null @@ -1,7 +0,0 @@ -system_prompt: | - You are a helpful translation assistant. - Supported languages: - {supported_languages} - - Translate the user provided text from {input_lang} to {output_lang}. - Return ONLY the translated text. Do not add quotes or extra commentary. \ No newline at end of file diff --git a/src-python/models/translation/prompt/translation_ollama.yml b/src-python/models/translation/prompt/translation_ollama.yml deleted file mode 100644 index 8dbe4927..00000000 --- a/src-python/models/translation/prompt/translation_ollama.yml +++ /dev/null @@ -1,7 +0,0 @@ -system_prompt: | - You are a helpful translation assistant. - Supported languages: - {supported_languages} - - Translate the user provided text from {input_lang} to {output_lang}. - Return ONLY the translated text. Do not add quotes or extra commentary. \ No newline at end of file diff --git a/src-python/models/translation/prompt/translation_openai.yml b/src-python/models/translation/prompt/translation_openai.yml deleted file mode 100644 index 8dbe4927..00000000 --- a/src-python/models/translation/prompt/translation_openai.yml +++ /dev/null @@ -1,7 +0,0 @@ -system_prompt: | - You are a helpful translation assistant. - Supported languages: - {supported_languages} - - Translate the user provided text from {input_lang} to {output_lang}. - Return ONLY the translated text. Do not add quotes or extra commentary. \ No newline at end of file diff --git a/src-python/models/translation/translation_gemini.py b/src-python/models/translation/translation_gemini.py index 1c8a0161..8b68dc33 100644 --- a/src-python/models/translation/translation_gemini.py +++ b/src-python/models/translation/translation_gemini.py @@ -4,14 +4,14 @@ from langchain_google_genai import ChatGoogleGenerativeAI try: from .translation_languages import translation_lang - from .translation_utils import loadPromptConfig + from .translation_utils import loadTranslatePromptConfig except Exception: import sys from os import path as os_path print(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) sys.path.append(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) from translation_languages import translation_lang - from translation_utils import loadPromptConfig + from translation_utils import loadTranslatePromptConfig logger = logging.getLogger("langchain_google_genai") logger.setLevel(logging.ERROR) @@ -57,9 +57,19 @@ class GeminiClient: self.model = None # プロンプト設定をYAMLファイルから読み込む - prompt_config = loadPromptConfig(root_path, "translation_gemini.yml") + prompt_config = loadTranslatePromptConfig(root_path, "translation_gemini.yml") self.supported_languages = list(translation_lang["Gemini_API"]["source"].keys()) self.prompt_template = prompt_config["system_prompt"] + # history config (optional) + self.history_cfg = prompt_config.get("history", { + "use_history": False, + "sources": [], + "max_messages": 0, + "max_chars": 0, + "header_template": "", + "item_template": "[{source}] {role}: {text}", + }) + self._context_history: list[dict] = [] self.gemini_llm = None @@ -91,6 +101,16 @@ class GeminiClient: api_key=self.api_key, ) + def setContextHistory(self, history_items: list[dict]) -> None: + """Set recent conversation history for prompt injection. + + Each item should be a dict containing: + - source: "chat" | "mic" | "speaker" + - text: message string + - timestamp: ISO format datetime string + """ + self._context_history = history_items or [] + def translate(self, text: str, input_lang: str, output_lang: str) -> str: system_prompt = self.prompt_template.format( supported_languages=self.supported_languages, @@ -98,6 +118,41 @@ class GeminiClient: output_lang=output_lang ) + # Inject recent conversation history if enabled by YAML config + if self.history_cfg.get("use_history"): + allowed_sources = set(self.history_cfg.get("sources", [])) + max_messages = int(self.history_cfg.get("max_messages", 0)) + max_chars = int(self.history_cfg.get("max_chars", 0)) + item_tmpl = self.history_cfg.get("item_template", "[{source}] {role}: {text}") + header_tmpl = self.history_cfg.get("header_template", "{history}") + + filtered = [h for h in self._context_history if h.get("source") in allowed_sources] + recent = filtered[-max_messages:] if max_messages > 0 else filtered + formatted_items = [] + for h in recent: + # Format timestamp as HH:MM to save tokens + timestamp_str = '' + if 'timestamp' in h: + from datetime import datetime + try: + ts = datetime.fromisoformat(h['timestamp']) + timestamp_str = ts.strftime('%H:%M') + except: + timestamp_str = '' + formatted_items.append( + item_tmpl.format( + timestamp=timestamp_str, + source=h.get("source", ""), + text=h.get("text", ""), + ) + ) + history_blob = "\n".join(formatted_items).strip() + if max_chars and len(history_blob) > max_chars: + history_blob = history_blob[-max_chars:] + history_header = header_tmpl.format(max_messages=max_messages, history=history_blob) + if history_header: + system_prompt = f"{system_prompt}\n\n{history_header}" + messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": text} diff --git a/src-python/models/translation/translation_groq.py b/src-python/models/translation/translation_groq.py new file mode 100644 index 00000000..3952f06f --- /dev/null +++ b/src-python/models/translation/translation_groq.py @@ -0,0 +1,201 @@ +from openai import OpenAI +from langchain_openai import ChatOpenAI +from pydantic import SecretStr + +try: + from .translation_languages import translation_lang + from .translation_utils import loadTranslatePromptConfig +except Exception: + import sys + from os import path as os_path + sys.path.append(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) + from translation_languages import translation_lang, loadTranslationLanguages + from translation_utils import loadTranslatePromptConfig + translation_lang = loadTranslationLanguages(path=".", force=True) + +def _authentication_check(api_key: str) -> bool: + """Check if the provided API key is valid by attempting to list models. + """ + try: + client = OpenAI( + api_key=api_key, + base_url="https://api.groq.com/openai/v1", + ) + client.models.list() + return True + except Exception: + return False + +def _get_available_text_models(api_key: str) -> list[str]: + """Extract only Groq models suitable for translation and chat applications. + """ + client = OpenAI( + api_key=api_key, + base_url="https://api.groq.com/openai/v1", + ) + res = client.models.list() + allowed_models = [] + + for model in res.data: + model_id = model.id + + # 除外対象のキーワード + exclude_keywords = [ + "whisper", # 音声認識 + "embedding", # 埋め込み + "image", # 画像生成 + "tts", # 音声合成 + "audio", # 音声系 + "search", # 検索補助モデル + "transcribe", # 音声→文字起こし + "diarize", # 話者分離 + "vision" # 画像入力系 + ] + + # 除外キーワードが含まれているモデルをスキップ + if any(kw in model_id.lower() for kw in exclude_keywords): + continue + + # テキスト処理用モデルのみ対象 + allowed_models.append(model_id) + + allowed_models.sort() + return allowed_models + +class GroqClient: + """Groq API Translation wrapper using OpenAI-compatible endpoint. + + Groq provides a fast LLM inference platform with an OpenAI-compatible API. + The API endpoint: https://api.groq.com/openai/v1 + """ + def __init__(self, root_path: str = None): + self.api_key = None + self.model = None + self.base_url = "https://api.groq.com/openai/v1" + + prompt_config = loadTranslatePromptConfig(root_path, "translation_groq.yml") + self.supported_languages = list(translation_lang["Groq_API"]["source"].keys()) + self.prompt_template = prompt_config["system_prompt"] + # history config (optional) + self.history_cfg = prompt_config.get("history", { + "use_history": False, + "sources": [], + "max_messages": 0, + "max_chars": 0, + "header_template": "", + "item_template": "[{source}] {role}: {text}", + }) + self._context_history: list[dict] = [] + + self.groq_llm = None + + def getModelList(self) -> list[str]: + return _get_available_text_models(self.api_key) if self.api_key else [] + + def getAuthKey(self) -> str: + return self.api_key + + def setAuthKey(self, api_key: str) -> bool: + result = _authentication_check(api_key) + if result: + self.api_key = api_key + return result + + def getModel(self) -> str: + return self.model + + def setModel(self, model: str) -> bool: + if model in self.getModelList(): + self.model = model + return True + else: + return False + + def updateClient(self) -> None: + self.groq_llm = ChatOpenAI( + base_url=self.base_url, + model=self.model, + api_key=SecretStr(self.api_key), + streaming=False, + ) + + def setContextHistory(self, history_items: list[dict]) -> None: + """Set recent conversation history for prompt injection. + + Each item should be a dict containing: + - source: "chat" | "mic" | "speaker" + - text: message string + - timestamp: ISO format datetime string + """ + self._context_history = history_items or [] + + def translate(self, text: str, input_lang: str, output_lang: str) -> str: + system_prompt = self.prompt_template.format( + supported_languages=self.supported_languages, + input_lang=input_lang, + output_lang=output_lang, + ) + + # Inject recent conversation history if enabled by YAML config + if self.history_cfg.get("use_history"): + allowed_sources = set(self.history_cfg.get("sources", [])) + max_messages = int(self.history_cfg.get("max_messages", 0)) + max_chars = int(self.history_cfg.get("max_chars", 0)) + item_tmpl = self.history_cfg.get("item_template", "[{source}] {role}: {text}") + header_tmpl = self.history_cfg.get("header_template", "{history}") + + filtered = [h for h in self._context_history if h.get("source") in allowed_sources] + recent = filtered[-max_messages:] if max_messages > 0 else filtered + formatted_items = [] + for h in recent: + # Format timestamp as HH:MM to save tokens + timestamp_str = '' + if 'timestamp' in h: + from datetime import datetime + try: + ts = datetime.fromisoformat(h['timestamp']) + timestamp_str = ts.strftime('%H:%M') + except: + timestamp_str = '' + formatted_items.append( + item_tmpl.format( + timestamp=timestamp_str, + source=h.get("source", ""), + text=h.get("text", ""), + ) + ) + history_blob = "\n".join(formatted_items).strip() + if max_chars and len(history_blob) > max_chars: + history_blob = history_blob[-max_chars:] + history_header = header_tmpl.format(max_messages=max_messages, history=history_blob) + if history_header: + system_prompt = f"{system_prompt}\n\n{history_header}" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": text}, + ] + + resp = self.groq_llm.invoke(messages) + content = "" + if isinstance(resp.content, str): + content = resp.content + elif isinstance(resp.content, list): + for part in resp.content: + if isinstance(part, str): + content += part + elif isinstance(part, dict) and "content" in part and isinstance(part["content"], str): + content += part["content"] + return content.strip() + +if __name__ == "__main__": + AUTH_KEY = "GROQ_API_KEY" + client = GroqClient() + client.setAuthKey(AUTH_KEY) + models = client.getModelList() + if models: + print("Available models:", models) + model = input("Select a model: ") + client.setModel(model) + client.updateClient() + print(client.translate("こんにちは世界", "Japanese", "English")) diff --git a/src-python/models/translation/translation_languages.py b/src-python/models/translation/translation_languages.py index eccd5665..311bbd95 100644 --- a/src-python/models/translation/translation_languages.py +++ b/src-python/models/translation/translation_languages.py @@ -41,14 +41,14 @@ def _load_languages(path: str, filename: str) -> str: Returns: Absolute path to the resource file """ - if os.path.exists(os.path.join(path, "_internal", "languages", "languages.yml")): - languages_path = os.path.join(path, "_internal", "languages", "languages.yml") - elif os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "models", "translation", "languages", "languages.yml")): - languages_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models", "translation", "languages", "languages.yml") - elif os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "languages", "languages.yml")): - languages_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "languages", "languages.yml") + if os.path.exists(os.path.join(path, "_internal", "translation_settings", "languages", filename)): + languages_path = os.path.join(path, "_internal", "translation_settings", "languages", filename) + elif os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "models", "translation", "translation_settings", "languages", filename)): + languages_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models", "translation", "translation_settings", "languages", filename) + elif os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "translation_settings", "languages", filename)): + languages_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "translation_settings", "languages", filename) else: - raise FileNotFoundError(f"Prompt file not found: {filename}") + raise FileNotFoundError(f"Languages file not found: {filename}") with open(languages_path, "r", encoding="utf-8") as f: return yaml.safe_load(f) @@ -99,7 +99,7 @@ def loadTranslationLanguages(path: str, force: bool = False) -> Dict[str, Any]: if _loaded and not force: return translation_lang - data = _load_languages(path, "languages/languages.yml") + data = _load_languages(path, "languages.yml") if not isinstance(data, dict): raise ValueError( diff --git a/src-python/models/translation/translation_lmstudio.py b/src-python/models/translation/translation_lmstudio.py index 7751dc16..0567fcad 100644 --- a/src-python/models/translation/translation_lmstudio.py +++ b/src-python/models/translation/translation_lmstudio.py @@ -1,40 +1,42 @@ -from openai import OpenAI from langchain_openai import ChatOpenAI from pydantic import SecretStr +import requests try: from .translation_languages import translation_lang - from .translation_utils import loadPromptConfig + from .translation_utils import loadTranslatePromptConfig except Exception: import sys from os import path as os_path - sys.path.append(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) - from translation_languages import translation_lang - from translation_utils import loadPromptConfig + sys.path.append(os_path.dirname(os_path.abspath(__file__))) + from translation_languages import translation_lang, loadTranslationLanguages + from translation_utils import loadTranslatePromptConfig + translation_lang = loadTranslationLanguages(path=".", force=True) -def _authentication_check(api_key: str, base_url: str | None = None) -> bool: +def _authentication_check(base_url: str | None = None) -> bool: """Check if the provided API key is valid by attempting to list models. """ try: - client = OpenAI(api_key=api_key, base_url=base_url) - client.models.list() - return True + response = requests.get(f"{base_url}/models", timeout=0.2) + if response.status_code == 200: + return True + else: + return False except Exception: return False -def _get_available_text_models(api_key: str, base_url: str | None = None) -> list[str]: +def _get_available_text_models(base_url: str | None = None) -> list[str]: """Extract the list of available text models from the LM Studio. """ try: - client = OpenAI(api_key=api_key, base_url=base_url) - res = client.models.list() - models = res.data + response = requests.get(f"{base_url}/models", timeout=0.2) + models = response.json()["data"] except Exception: models = [] allowed_models = [] for model in models: - allowed_models.append(model.id) + allowed_models.append(model["id"]) allowed_models.sort() return allowed_models @@ -48,9 +50,19 @@ class LMStudioClient: self.model = None self.base_url = base_url # None の場合は公式エンドポイント - prompt_config = loadPromptConfig(root_path, "translation_lmstudio.yml") + prompt_config = loadTranslatePromptConfig(root_path, "translation_lmstudio.yml") self.supported_languages = list(translation_lang["LMStudio"]["source"].keys()) self.prompt_template = prompt_config["system_prompt"] + # history config (optional) + self.history_cfg = prompt_config.get("history", { + "use_history": False, + "sources": [], + "max_messages": 0, + "max_chars": 0, + "header_template": "", + "item_template": "[{source}] {role}: {text}", + }) + self._context_history: list[dict] = [] self.openai_llm = None @@ -58,13 +70,13 @@ class LMStudioClient: return self.base_url def setBaseURL(self, base_url: str | None) -> None: - result = _authentication_check(api_key=self.api_key, base_url=base_url) + result = _authentication_check(base_url=base_url) if result: self.base_url = base_url return result def getModelList(self) -> list[str]: - return _get_available_text_models(api_key=self.api_key, base_url=self.base_url) if self.base_url else [] + return _get_available_text_models(base_url=self.base_url) if self.base_url else [] def getModel(self) -> str: return self.model @@ -84,12 +96,58 @@ class LMStudioClient: streaming=False, ) + def setContextHistory(self, history_items: list[dict]) -> None: + """Set recent conversation history for prompt injection. + + Each item should be a dict containing: + - source: "chat" | "mic" | "speaker" + - text: message string + - timestamp: ISO format datetime string + """ + self._context_history = history_items or [] + def translate(self, text: str, input_lang: str, output_lang: str) -> str: system_prompt = self.prompt_template.format( supported_languages=self.supported_languages, input_lang=input_lang, output_lang=output_lang, ) + + # Inject recent conversation history if enabled by YAML config + if self.history_cfg.get("use_history"): + allowed_sources = set(self.history_cfg.get("sources", [])) + max_messages = int(self.history_cfg.get("max_messages", 0)) + max_chars = int(self.history_cfg.get("max_chars", 0)) + item_tmpl = self.history_cfg.get("item_template", "[{source}] {role}: {text}") + header_tmpl = self.history_cfg.get("header_template", "{history}") + + filtered = [h for h in self._context_history if h.get("source") in allowed_sources] + recent = filtered[-max_messages:] if max_messages > 0 else filtered + formatted_items = [] + for h in recent: + # Format timestamp as HH:MM to save tokens + timestamp_str = '' + if 'timestamp' in h: + from datetime import datetime + try: + ts = datetime.fromisoformat(h['timestamp']) + timestamp_str = ts.strftime('%H:%M') + except: + timestamp_str = '' + formatted_items.append( + item_tmpl.format( + timestamp=timestamp_str, + source=h.get("source", ""), + text=h.get("text", ""), + ) + ) + history_blob = "\n".join(formatted_items).strip() + if max_chars and len(history_blob) > max_chars: + history_blob = history_blob[-max_chars:] + history_header = header_tmpl.format(max_messages=max_messages, history=history_blob) + if history_header: + system_prompt = f"{system_prompt}\n\n{history_header}" + messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": text}, @@ -108,7 +166,7 @@ class LMStudioClient: return content.strip() if __name__ == "__main__": - client = LMStudioClient(base_url="http://192.168.68.110:1234/v1") + client = LMStudioClient(base_url="http://127.0.0.1:1234/v1") models = client.getModelList() if models: print("Available models:", models) diff --git a/src-python/models/translation/translation_ollama.py b/src-python/models/translation/translation_ollama.py index 4152c953..8d1cae94 100644 --- a/src-python/models/translation/translation_ollama.py +++ b/src-python/models/translation/translation_ollama.py @@ -3,19 +3,20 @@ from langchain_ollama import ChatOllama try: from .translation_languages import translation_lang - from .translation_utils import loadPromptConfig + from .translation_utils import loadTranslatePromptConfig except Exception: import sys from os import path as os_path - sys.path.append(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) - from translation_languages import translation_lang - from translation_utils import loadPromptConfig + sys.path.append(os_path.dirname(os_path.abspath(__file__))) + from translation_languages import translation_lang, loadTranslationLanguages + from translation_utils import loadTranslatePromptConfig + translation_lang = loadTranslationLanguages(path=".", force=True) def _authentication_check(base_url: str | None = None) -> bool: """Check authentication for Ollama API. """ try: - response = requests.get(f"{base_url}") + response = requests.get(f"{base_url}", timeout=0.2) if response.status_code == 200: return True else: @@ -47,9 +48,19 @@ class OllamaClient: self.model = None self.base_url = "http://localhost:11434" - prompt_config = loadPromptConfig(root_path, "translation_ollama.yml") + prompt_config = loadTranslatePromptConfig(root_path, "translation_ollama.yml") self.supported_languages = list(translation_lang["Ollama"]["source"].keys()) self.prompt_template = prompt_config["system_prompt"] + # history config (optional) + self.history_cfg = prompt_config.get("history", { + "use_history": False, + "sources": [], + "max_messages": 0, + "max_chars": 0, + "header_template": "", + "item_template": "[{source}] {role}: {text}", + }) + self._context_history: list[dict] = [] self.openai_llm = None @@ -78,12 +89,58 @@ class OllamaClient: streaming=False, ) + def setContextHistory(self, history_items: list[dict]) -> None: + """Set recent conversation history for prompt injection. + + Each item should be a dict containing: + - source: "chat" | "mic" | "speaker" + - text: message string + - timestamp: ISO format datetime string + """ + self._context_history = history_items or [] + def translate(self, text: str, input_lang: str, output_lang: str) -> str: system_prompt = self.prompt_template.format( supported_languages=self.supported_languages, input_lang=input_lang, output_lang=output_lang, ) + + # Inject recent conversation history if enabled by YAML config + if self.history_cfg.get("use_history"): + allowed_sources = set(self.history_cfg.get("sources", [])) + max_messages = int(self.history_cfg.get("max_messages", 0)) + max_chars = int(self.history_cfg.get("max_chars", 0)) + item_tmpl = self.history_cfg.get("item_template", "[{source}] {role}: {text}") + header_tmpl = self.history_cfg.get("header_template", "{history}") + + filtered = [h for h in self._context_history if h.get("source") in allowed_sources] + recent = filtered[-max_messages:] if max_messages > 0 else filtered + formatted_items = [] + for h in recent: + # Format timestamp as HH:MM to save tokens + timestamp_str = '' + if 'timestamp' in h: + from datetime import datetime + try: + ts = datetime.fromisoformat(h['timestamp']) + timestamp_str = ts.strftime('%H:%M') + except: + timestamp_str = '' + formatted_items.append( + item_tmpl.format( + timestamp=timestamp_str, + source=h.get("source", ""), + text=h.get("text", ""), + ) + ) + history_blob = "\n".join(formatted_items).strip() + if max_chars and len(history_blob) > max_chars: + history_blob = history_blob[-max_chars:] + history_header = header_tmpl.format(max_messages=max_messages, history=history_blob) + if history_header: + system_prompt = f"{system_prompt}\n\n{history_header}" + messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": text}, diff --git a/src-python/models/translation/translation_openai.py b/src-python/models/translation/translation_openai.py index b7115e51..5c21f36f 100644 --- a/src-python/models/translation/translation_openai.py +++ b/src-python/models/translation/translation_openai.py @@ -4,13 +4,14 @@ from pydantic import SecretStr try: from .translation_languages import translation_lang - from .translation_utils import loadPromptConfig + from .translation_utils import loadTranslatePromptConfig except Exception: import sys from os import path as os_path sys.path.append(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) - from translation_languages import translation_lang - from translation_utils import loadPromptConfig + from translation_languages import translation_lang, loadTranslationLanguages + from translation_utils import loadTranslatePromptConfig + translation_lang = loadTranslationLanguages(path=".", force=True) def _authentication_check(api_key: str, base_url: str | None = None) -> bool: """Check if the provided API key is valid by attempting to list models. @@ -68,9 +69,19 @@ class OpenAIClient: self.model = None self.base_url = base_url # None の場合は公式エンドポイント - prompt_config = loadPromptConfig(root_path, "translation_openai.yml") + prompt_config = loadTranslatePromptConfig(root_path, "translation_openai.yml") self.supported_languages = list(translation_lang["OpenAI_API"]["source"].keys()) self.prompt_template = prompt_config["system_prompt"] + # history config (optional) + self.history_cfg = prompt_config.get("history", { + "use_history": False, + "sources": [], + "max_messages": 0, + "max_chars": 0, + "header_template": "", + "item_template": "[{source}] {role}: {text}", + }) + self._context_history: list[dict] = [] self.openai_llm = None @@ -104,12 +115,62 @@ class OpenAIClient: streaming=False, ) + def setContextHistory(self, history_items: list[dict]) -> None: + """Set recent conversation history for prompt injection. + + Each item should be a dict containing: + - source: "chat" | "mic" | "speaker" + - text: message string + - timestamp: ISO format datetime string + """ + self._context_history = history_items or [] + def translate(self, text: str, input_lang: str, output_lang: str) -> str: system_prompt = self.prompt_template.format( supported_languages=self.supported_languages, input_lang=input_lang, output_lang=output_lang, ) + + # Inject recent conversation history if enabled by YAML config + if self.history_cfg.get("use_history"): + allowed_sources = set(self.history_cfg.get("sources", [])) + max_messages = int(self.history_cfg.get("max_messages", 0)) + max_chars = int(self.history_cfg.get("max_chars", 0)) + item_tmpl = self.history_cfg.get("item_template", "[{source}] {role}: {text}") + header_tmpl = self.history_cfg.get("header_template", "{history}") + + # filter by source and take newest N + filtered = [h for h in self._context_history if h.get("source") in allowed_sources] + recent = filtered[-max_messages:] if max_messages > 0 else filtered + # format items + formatted_items = [] + for h in recent: + # Format timestamp as HH:MM to save tokens + timestamp_str = '' + if 'timestamp' in h: + from datetime import datetime + try: + ts = datetime.fromisoformat(h['timestamp']) + timestamp_str = ts.strftime('%H:%M') + except: + timestamp_str = '' + formatted_items.append( + item_tmpl.format( + timestamp=timestamp_str, + source=h.get("source", ""), + text=h.get("text", ""), + ) + ) + history_blob = "\n".join(formatted_items).strip() + # truncate by char limit to mitigate token use + if max_chars and len(history_blob) > max_chars: + history_blob = history_blob[-max_chars:] + # assemble header and append to system prompt + history_header = header_tmpl.format(max_messages=max_messages, history=history_blob) + if history_header: + system_prompt = f"{system_prompt}\n\n{history_header}" + messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": text}, @@ -128,7 +189,7 @@ class OpenAIClient: return content.strip() if __name__ == "__main__": - AUTH_KEY = "OPENAI_API_KEY" + AUTH_KEY = input("OPENAI_API_KEY: ") client = OpenAIClient() client.setAuthKey(AUTH_KEY) models = client.getModelList() diff --git a/src-python/models/translation/translation_openrouter.py b/src-python/models/translation/translation_openrouter.py new file mode 100644 index 00000000..2dffba58 --- /dev/null +++ b/src-python/models/translation/translation_openrouter.py @@ -0,0 +1,199 @@ +import requests +from openai import OpenAI +from langchain_openai import ChatOpenAI +from pydantic import SecretStr + +try: + from .translation_languages import translation_lang + from .translation_utils import loadTranslatePromptConfig +except Exception: + import sys + from os import path as os_path + sys.path.append(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) + from translation_languages import translation_lang, loadTranslationLanguages + from translation_utils import loadTranslatePromptConfig + translation_lang = loadTranslationLanguages(path=".", force=True) + +def _authentication_check(api_key: str) -> bool: + """Check if the provided API key is valid by attempting to list models. + """ + + url = "https://openrouter.ai/api/v1/auth/key" + headers = { + "Authorization": f"Bearer {api_key}" + } + + r = requests.get(url, headers=headers, timeout=10) + + return r.status_code == 200 + +def _get_available_text_models(api_key: str, base_url: str | None = None) -> list[str]: + """Extract only OpenRouter models suitable for translation and chat applications. + """ + client = OpenAI(api_key=api_key, base_url=base_url) + res = client.models.list() + allowed_models = [] + + for model in res.data: + model_id = model.id + + # 除外対象のキーワード + exclude_keywords = [ + "whisper", # 音声認識 + "embedding", # 埋め込み + "image", # 画像生成 + "tts", # 音声合成 + "audio", # 音声系 + "search", # 検索補助モデル + "transcribe", # 音声→文字起こし + "diarize", # 話者分離 + "vision" # 画像入力系 + ] + + # 除外キーワードが含まれているモデルをスキップ + if any(kw in model_id.lower() for kw in exclude_keywords): + continue + + # テキスト処理用モデルのみ対象 + allowed_models.append(model_id) + + allowed_models.sort() + return allowed_models + +class OpenRouterClient: + """OpenRouter API Translation wrapper using OpenAI-compatible endpoint. + + OpenRouter provides access to various LLM models via a unified API. + The API endpoint: https://openrouter.ai/api/v1 + """ + def __init__(self, root_path: str = None): + self.api_key = None + self.model = None + self.base_url = "https://openrouter.ai/api/v1" + + prompt_config = loadTranslatePromptConfig(root_path, "translation_openrouter.yml") + self.supported_languages = list(translation_lang["OpenRouter_API"]["source"].keys()) + self.prompt_template = prompt_config["system_prompt"] + # history config (optional) + self.history_cfg = prompt_config.get("history", { + "use_history": False, + "sources": [], + "max_messages": 0, + "max_chars": 0, + "header_template": "", + "item_template": "[{source}] {role}: {text}", + }) + self._context_history: list[dict] = [] + + self.openrouter_llm = None + + def getModelList(self) -> list[str]: + return _get_available_text_models(self.api_key, self.base_url) if self.api_key else [] + + def getAuthKey(self) -> str: + return self.api_key + + def setAuthKey(self, api_key: str) -> bool: + result = _authentication_check(api_key) + if result: + self.api_key = api_key + return result + + def getModel(self) -> str: + return self.model + + def setModel(self, model: str) -> bool: + if model in self.getModelList(): + self.model = model + return True + else: + return False + + def updateClient(self) -> None: + self.openrouter_llm = ChatOpenAI( + base_url=self.base_url, + model=self.model, + api_key=SecretStr(self.api_key), + streaming=False, + ) + + def setContextHistory(self, history_items: list[dict]) -> None: + """Set recent conversation history for prompt injection. + + Each item should be a dict containing: + - source: "chat" | "mic" | "speaker" + - text: message string + - timestamp: ISO format datetime string + """ + self._context_history = history_items or [] + + def translate(self, text: str, input_lang: str, output_lang: str) -> str: + system_prompt = self.prompt_template.format( + supported_languages=self.supported_languages, + input_lang=input_lang, + output_lang=output_lang, + ) + + # Inject recent conversation history if enabled by YAML config + if self.history_cfg.get("use_history"): + allowed_sources = set(self.history_cfg.get("sources", [])) + max_messages = int(self.history_cfg.get("max_messages", 0)) + max_chars = int(self.history_cfg.get("max_chars", 0)) + item_tmpl = self.history_cfg.get("item_template", "[{source}] {role}: {text}") + header_tmpl = self.history_cfg.get("header_template", "{history}") + + filtered = [h for h in self._context_history if h.get("source") in allowed_sources] + recent = filtered[-max_messages:] if max_messages > 0 else filtered + formatted_items = [] + for h in recent: + # Format timestamp as HH:MM to save tokens + timestamp_str = '' + if 'timestamp' in h: + from datetime import datetime + try: + ts = datetime.fromisoformat(h['timestamp']) + timestamp_str = ts.strftime('%H:%M') + except: + timestamp_str = '' + formatted_items.append( + item_tmpl.format( + timestamp=timestamp_str, + source=h.get("source", ""), + text=h.get("text", ""), + ) + ) + history_blob = "\n".join(formatted_items).strip() + if max_chars and len(history_blob) > max_chars: + history_blob = history_blob[-max_chars:] + history_header = header_tmpl.format(max_messages=max_messages, history=history_blob) + if history_header: + system_prompt = f"{system_prompt}\n\n{history_header}" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": text}, + ] + + resp = self.openrouter_llm.invoke(messages) + content = "" + if isinstance(resp.content, str): + content = resp.content + elif isinstance(resp.content, list): + for part in resp.content: + if isinstance(part, str): + content += part + elif isinstance(part, dict) and "content" in part and isinstance(part["content"], str): + content += part["content"] + return content.strip() + +if __name__ == "__main__": + AUTH_KEY = input("OPENROUTER_API_KEY: ") + client = OpenRouterClient() + client.setAuthKey(AUTH_KEY) + models = client.getModelList() + if models: + print("Available models:", models) + model = input("Select a model: ") + client.setModel(model) + client.updateClient() + print(client.translate("こんにちは世界", "Japanese", "English")) \ No newline at end of file diff --git a/src-python/models/translation/translation_plamo.py b/src-python/models/translation/translation_plamo.py index ea54fd61..1752f7fa 100644 --- a/src-python/models/translation/translation_plamo.py +++ b/src-python/models/translation/translation_plamo.py @@ -4,13 +4,14 @@ from pydantic import SecretStr try: from .translation_languages import translation_lang - from .translation_utils import loadPromptConfig + from .translation_utils import loadTranslatePromptConfig except Exception: import sys from os import path as os_path sys.path.append(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) - from translation_languages import translation_lang - from translation_utils import loadPromptConfig + from translation_languages import translation_lang, loadTranslationLanguages + from translation_utils import loadTranslatePromptConfig + translation_lang = loadTranslationLanguages(path=".", force=True) BASE_URL = "https://api.platform.preferredai.jp/v1" @@ -43,9 +44,19 @@ class PlamoClient: self.base_url = BASE_URL self.model = None - prompt_config = loadPromptConfig(root_path, "translation_plamo.yml") + prompt_config = loadTranslatePromptConfig(root_path, "translation_plamo.yml") self.supported_languages = list(translation_lang["Plamo_API"]["source"].keys()) self.prompt_template = prompt_config["system_prompt"] + # history config (optional) + self.history_cfg = prompt_config.get("history", { + "use_history": False, + "sources": [], + "max_messages": 0, + "max_chars": 0, + "header_template": "", + "item_template": "[{source}] {role}: {text}", + }) + self._context_history: list[dict] = [] self.plamo_llm = None @@ -79,12 +90,58 @@ class PlamoClient: api_key=SecretStr(self.api_key), ) + def setContextHistory(self, history_items: list[dict]) -> None: + """Set recent conversation history for prompt injection. + + Each item should be a dict containing: + - source: "chat" | "mic" | "speaker" + - text: message string + - timestamp: ISO format datetime string + """ + self._context_history = history_items or [] + def translate(self, text: str, input_lang: str, output_lang: str) -> str: system_prompt = self.prompt_template.format( supported_languages=self.supported_languages, input_lang=input_lang, output_lang=output_lang ) + + # Inject recent conversation history if enabled by YAML config + if self.history_cfg.get("use_history"): + allowed_sources = set(self.history_cfg.get("sources", [])) + max_messages = int(self.history_cfg.get("max_messages", 0)) + max_chars = int(self.history_cfg.get("max_chars", 0)) + item_tmpl = self.history_cfg.get("item_template", "[{source}] {role}: {text}") + header_tmpl = self.history_cfg.get("header_template", "{history}") + + filtered = [h for h in self._context_history if h.get("source") in allowed_sources] + recent = filtered[-max_messages:] if max_messages > 0 else filtered + formatted_items = [] + for h in recent: + # Format timestamp as HH:MM to save tokens + timestamp_str = '' + if 'timestamp' in h: + from datetime import datetime + try: + ts = datetime.fromisoformat(h['timestamp']) + timestamp_str = ts.strftime('%H:%M') + except: + timestamp_str = '' + formatted_items.append( + item_tmpl.format( + timestamp=timestamp_str, + source=h.get("source", ""), + text=h.get("text", ""), + ) + ) + history_blob = "\n".join(formatted_items).strip() + if max_chars and len(history_blob) > max_chars: + history_blob = history_blob[-max_chars:] + history_header = header_tmpl.format(max_messages=max_messages, history=history_blob) + if history_header: + system_prompt = f"{system_prompt}\n\n{history_header}" + messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": text}, diff --git a/src-python/models/translation/languages/languages.yml b/src-python/models/translation/translation_settings/languages/languages.yml similarity index 99% rename from src-python/models/translation/languages/languages.yml rename to src-python/models/translation/translation_settings/languages/languages.yml index cfde921e..04772215 100644 --- a/src-python/models/translation/languages/languages.yml +++ b/src-python/models/translation/translation_settings/languages/languages.yml @@ -769,3 +769,11 @@ LMStudio: Ollama: source: *openai_langs target: *openai_langs + +Groq_API: + source: *openai_langs + target: *openai_langs + +OpenRouter_API: + source: *openai_langs + target: *openai_langs diff --git a/src-python/models/translation/translation_settings/prompt/translation_gemini.yml b/src-python/models/translation/translation_settings/prompt/translation_gemini.yml new file mode 100644 index 00000000..96e67a7e --- /dev/null +++ b/src-python/models/translation/translation_settings/prompt/translation_gemini.yml @@ -0,0 +1,16 @@ +system_prompt: | + You are a helpful translation assistant. + Supported languages: + {supported_languages} + + Translate the user provided text from {input_lang} to {output_lang}. + Return ONLY the translated text. Do not add quotes or extra commentary. +history: + use_history: true + sources: [chat, mic, speaker] + max_messages: 5 + max_chars: 4000 + header_template: | + Conversation context (recent {max_messages} messages): + {history} + item_template: "[{timestamp}][{source}] {text}" \ No newline at end of file diff --git a/src-python/models/translation/translation_settings/prompt/translation_groq.yml b/src-python/models/translation/translation_settings/prompt/translation_groq.yml new file mode 100644 index 00000000..03d38844 --- /dev/null +++ b/src-python/models/translation/translation_settings/prompt/translation_groq.yml @@ -0,0 +1,16 @@ +system_prompt: | + You are a helpful translation assistant. + Supported languages: + {supported_languages} + + Translate the user provided text from {input_lang} to {output_lang}. + Return ONLY the translated text. Do not add quotes or extra commentary. +history: + use_history: true + sources: [chat, mic, speaker] + max_messages: 5 + max_chars: 4000 + header_template: | + Conversation context (recent {max_messages} messages): + {history} + item_template: "[{timestamp}][{source}] {text}" diff --git a/src-python/models/translation/translation_settings/prompt/translation_lmstudio.yml b/src-python/models/translation/translation_settings/prompt/translation_lmstudio.yml new file mode 100644 index 00000000..96e67a7e --- /dev/null +++ b/src-python/models/translation/translation_settings/prompt/translation_lmstudio.yml @@ -0,0 +1,16 @@ +system_prompt: | + You are a helpful translation assistant. + Supported languages: + {supported_languages} + + Translate the user provided text from {input_lang} to {output_lang}. + Return ONLY the translated text. Do not add quotes or extra commentary. +history: + use_history: true + sources: [chat, mic, speaker] + max_messages: 5 + max_chars: 4000 + header_template: | + Conversation context (recent {max_messages} messages): + {history} + item_template: "[{timestamp}][{source}] {text}" \ No newline at end of file diff --git a/src-python/models/translation/translation_settings/prompt/translation_ollama.yml b/src-python/models/translation/translation_settings/prompt/translation_ollama.yml new file mode 100644 index 00000000..96e67a7e --- /dev/null +++ b/src-python/models/translation/translation_settings/prompt/translation_ollama.yml @@ -0,0 +1,16 @@ +system_prompt: | + You are a helpful translation assistant. + Supported languages: + {supported_languages} + + Translate the user provided text from {input_lang} to {output_lang}. + Return ONLY the translated text. Do not add quotes or extra commentary. +history: + use_history: true + sources: [chat, mic, speaker] + max_messages: 5 + max_chars: 4000 + header_template: | + Conversation context (recent {max_messages} messages): + {history} + item_template: "[{timestamp}][{source}] {text}" \ No newline at end of file diff --git a/src-python/models/translation/translation_settings/prompt/translation_openai.yml b/src-python/models/translation/translation_settings/prompt/translation_openai.yml new file mode 100644 index 00000000..9b71cfbb --- /dev/null +++ b/src-python/models/translation/translation_settings/prompt/translation_openai.yml @@ -0,0 +1,16 @@ +system_prompt: | + You are a helpful translation assistant. + Supported languages: + {supported_languages} + + Translate the user provided text from {input_lang} to {output_lang}. + Return ONLY the translated text. Do not add quotes or extra commentary. +history: + use_history: true + sources: [chat, mic, speaker] # 取り込み対象の履歴種別 + max_messages: 5 # 注入する履歴件数の上限(新しい順) + max_chars: 4000 # 履歴整形後の最大文字数(超過時は先頭を切り捨て) + header_template: | + Conversation context (recent {max_messages} messages): + {history} + item_template: "[{timestamp}][{source}] {text}" \ No newline at end of file diff --git a/src-python/models/translation/translation_settings/prompt/translation_openrouter.yml b/src-python/models/translation/translation_settings/prompt/translation_openrouter.yml new file mode 100644 index 00000000..03d38844 --- /dev/null +++ b/src-python/models/translation/translation_settings/prompt/translation_openrouter.yml @@ -0,0 +1,16 @@ +system_prompt: | + You are a helpful translation assistant. + Supported languages: + {supported_languages} + + Translate the user provided text from {input_lang} to {output_lang}. + Return ONLY the translated text. Do not add quotes or extra commentary. +history: + use_history: true + sources: [chat, mic, speaker] + max_messages: 5 + max_chars: 4000 + header_template: | + Conversation context (recent {max_messages} messages): + {history} + item_template: "[{timestamp}][{source}] {text}" diff --git a/src-python/models/translation/prompt/translation_plamo.yml b/src-python/models/translation/translation_settings/prompt/translation_plamo.yml similarity index 55% rename from src-python/models/translation/prompt/translation_plamo.yml rename to src-python/models/translation/translation_settings/prompt/translation_plamo.yml index c0afe533..a97a57b9 100644 --- a/src-python/models/translation/prompt/translation_plamo.yml +++ b/src-python/models/translation/translation_settings/prompt/translation_plamo.yml @@ -4,4 +4,13 @@ system_prompt: | {supported_languages} Translate the following text from {input_lang} to {output_lang}. - output only the translated text without any additional commentary. \ No newline at end of file + output only the translated text without any additional commentary. +history: + use_history: true + sources: [chat, mic, speaker] + max_messages: 5 + max_chars: 4000 + header_template: | + Conversation context (recent {max_messages} messages): + {history} + item_template: "[{timestamp}][{source}] {text}" \ No newline at end of file diff --git a/src-python/models/translation/translation_translator.py b/src-python/models/translation/translation_translator.py index 249a09f1..d311f88a 100644 --- a/src-python/models/translation/translation_translator.py +++ b/src-python/models/translation/translation_translator.py @@ -15,6 +15,8 @@ try: from .translation_openai import OpenAIClient from .translation_lmstudio import LMStudioClient from .translation_ollama import OllamaClient + from .translation_groq import GroqClient + from .translation_openrouter import OpenRouterClient except Exception: import sys sys.path.append(os_path.dirname(os_path.dirname(os_path.dirname(os_path.abspath(__file__))))) @@ -25,6 +27,8 @@ except Exception: from translation_openai import OpenAIClient from translation_lmstudio import LMStudioClient from translation_ollama import OllamaClient + from translation_groq import GroqClient + from translation_openrouter import OpenRouterClient import ctranslate2 import transformers @@ -50,6 +54,8 @@ class Translator: self.plamo_client: Optional[PlamoClient] = None self.gemini_client: Optional[GeminiClient] = None self.openai_client: Optional[OpenAIClient] = None + self.groq_client: Optional[GroqClient] = None + self.openrouter_client: Optional[OpenRouterClient] = None self.lmstudio_client: LMStudioClient[LMStudioClient] = None self.ollama_client: OllamaClient[OllamaClient] = None self.ctranslate2_translator: Any = None @@ -176,6 +182,84 @@ class Translator: """Update the OpenAI client (fetch available models).""" self.openai_client.updateClient() + def authenticationGroqAuthKey(self, auth_key: str, root_path: str = None) -> bool: + """Authenticate Groq API with the provided key. + + Returns True on success, False on failure. + """ + self.groq_client = GroqClient(root_path=root_path) + if self.groq_client.setAuthKey(auth_key): + return True + else: + self.groq_client = None + return False + + def getGroqModelList(self) -> list[str]: + """Get available Groq models. + + Returns a list of model names, or an empty list on failure. + """ + if self.groq_client is None: + return [] + return self.groq_client.getModelList() + + def setGroqModel(self, model: str) -> bool: + """Change the Groq model used for translation. + + Returns True on success, False on failure. + """ + if self.groq_client is None: + return False + return self.groq_client.setModel(model) + + def updateGroqClient(self) -> None: + """Update the Groq client (fetch available models).""" + self.groq_client.updateClient() + + def authenticationOpenRouterAuthKey(self, auth_key: str, root_path: str = None) -> bool: + """Authenticate OpenRouter API with the provided key. + + Returns True on success, False on failure. + """ + self.openrouter_client = OpenRouterClient(root_path=root_path) + if self.openrouter_client.setAuthKey(auth_key): + return True + else: + self.openrouter_client = None + return False + + def getOpenRouterModelList(self) -> list[str]: + """Get available OpenRouter models. + + Returns a list of model names, or an empty list on failure. + """ + if self.openrouter_client is None: + return [] + return self.openrouter_client.getModelList() + + def setOpenRouterModel(self, model: str) -> bool: + """Change the OpenRouter model used for translation. + + Returns True on success, False on failure. + """ + if self.openrouter_client is None: + return False + return self.openrouter_client.setModel(model) + + def updateOpenRouterClient(self) -> None: + """Update the OpenRouter client (fetch available models).""" + self.openrouter_client.updateClient() + + def getLMStudioConnected(self) -> bool: + """Get LM Studio connection status. + + Returns True if connected, False otherwise. + """ + if self.lmstudio_client is None: + return False + else: + return True + def setLMStudioClientURL(self, base_url: str | None = None, root_path: str = None) -> bool: """Authenticate LM Studio with the provided base URL. @@ -207,13 +291,26 @@ class Translator: """Update the LM Studio client (fetch available models).""" self.lmstudio_client.updateClient() + def getOllamaConnected(self) -> bool: + """Get Ollama connection status. + + Returns True if connected, False otherwise. + """ + if self.ollama_client is None: + return False + else: + return True + def checkOllamaClient(self, root_path: str = None) -> bool: """Check if Ollama client is available. Returns True if Ollama is reachable, False otherwise. """ self.ollama_client = OllamaClient(root_path=root_path) - return self.ollama_client.authenticationCheck() + result = self.ollama_client.authenticationCheck() + if result is False: + self.ollama_client = None + return result def getOllamaModelList(self, root_path: str = None) -> bool: """Initialize Ollama client and fetch available models. @@ -328,9 +425,18 @@ class Translator: target_language = translation_lang[translator_name]["target"][target_language] return source_language, target_language - def translate(self, translator_name: str, weight_type: str, source_language: str, target_language: str, target_country: str, message: str) -> Any: + def translate(self, translator_name: str, weight_type: str, source_language: str, target_language: str, target_country: str, message: str, context_history: Optional[list[dict]] = None) -> Any: """Translate `message` using the named translator backend. + Args: + translator_name: Name of the translator backend to use + weight_type: Model weight type for CTranslate2 + source_language: Source language name + target_language: Target language name + target_country: Target country for locale-specific translations + message: Text to translate + context_history: Optional conversation context (Chat/Mic/Speaker messages) + Returns translated string on success, or False on failure. When source_language == target_language the original message is returned. """ @@ -363,6 +469,8 @@ class Translator: if self.plamo_client is None: result = False else: + if context_history: + self.plamo_client.setContextHistory(context_history) result = self.plamo_client.translate( message, input_lang=source_language, @@ -372,6 +480,8 @@ class Translator: if self.gemini_client is None: result = False else: + if context_history: + self.gemini_client.setContextHistory(context_history) result = self.gemini_client.translate( message, input_lang=source_language, @@ -381,15 +491,41 @@ class Translator: if self.openai_client is None: result = False else: + if context_history: + self.openai_client.setContextHistory(context_history) result = self.openai_client.translate( message, input_lang=source_language, output_lang=target_language, ) + case "Groq_API": + if self.groq_client is None: + result = False + else: + if context_history: + self.groq_client.setContextHistory(context_history) + result = self.groq_client.translate( + message, + input_lang=source_language, + output_lang=target_language, + ) + case "OpenRouter_API": + if self.openrouter_client is None: + result = False + else: + if context_history: + self.openrouter_client.setContextHistory(context_history) + result = self.openrouter_client.translate( + message, + input_lang=source_language, + output_lang=target_language, + ) case "LMStudio": if self.lmstudio_client is None: result = False else: + if context_history: + self.lmstudio_client.setContextHistory(context_history) result = self.lmstudio_client.translate( message, input_lang=source_language, @@ -399,6 +535,8 @@ class Translator: if self.ollama_client is None: result = False else: + if context_history: + self.ollama_client.setContextHistory(context_history) result = self.ollama_client.translate( message, input_lang=source_language, diff --git a/src-python/models/translation/translation_utils.py b/src-python/models/translation/translation_utils.py index 8c3e4e46..23f24b15 100644 --- a/src-python/models/translation/translation_utils.py +++ b/src-python/models/translation/translation_utils.py @@ -101,16 +101,16 @@ def downloadCTranslate2Tokenizer(path: str, weight_type: str = "m2m100_418M-ct2- tokenizer_path = os_path.join("./weights", "ctranslate2", directory_name, "tokenizer") transformers.AutoTokenizer.from_pretrained(tokenizer, cache_dir=tokenizer_path) -def loadPromptConfig(root_path: str | None = None, prompt_filename: str | None = None) -> dict: +def loadTranslatePromptConfig(root_path: str | None = None, prompt_filename: str | None = None) -> dict: # PyInstaller 展開後 - if root_path and prompt_filename and os_path.exists(os_path.join(root_path, "_internal", "prompt", prompt_filename)): - prompt_path = os_path.join(root_path, "_internal", "prompt", prompt_filename) + if root_path and prompt_filename and os_path.exists(os_path.join(root_path, "_internal", "translation_settings", "prompt", prompt_filename)): + prompt_path = os_path.join(root_path, "_internal", "translation_settings", "prompt", prompt_filename) # src-python 直下実行 - elif prompt_filename and os_path.exists(os_path.join(os_path.dirname(__file__), "models", "translation", "prompt", prompt_filename)): - prompt_path = os_path.join(os_path.dirname(__file__), "models", "translation", "prompt", prompt_filename) + elif prompt_filename and os_path.exists(os_path.join(os_path.dirname(__file__), "models", "translation", "translation_settings", "prompt", prompt_filename)): + prompt_path = os_path.join(os_path.dirname(__file__), "models", "translation", "translation_settings", "prompt", prompt_filename) # translation フォルダ直下実行 - elif prompt_filename and os_path.exists(os_path.join(os_path.dirname(__file__), "prompt", prompt_filename)): - prompt_path = os_path.join(os_path.dirname(__file__), "prompt", prompt_filename) + elif prompt_filename and os_path.exists(os_path.join(os_path.dirname(__file__), "translation_settings", "prompt", prompt_filename)): + prompt_path = os_path.join(os_path.dirname(__file__), "translation_settings", "prompt", prompt_filename) else: raise FileNotFoundError(f"Prompt file not found: {prompt_filename}") with open(prompt_path, "r", encoding="utf-8") as f: diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 10b04d98..d7695cf9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VRCT", - "version": "3.3.1", + "version": "3.3.2", "identifier": "com.vrct.app", "build": { "beforeDevCommand": "", @@ -11,20 +11,25 @@ }, "app": { "enableGTKAppId": false, - "windows": [{ - "title": "VRCT", - "center": true, - "width": 450, - "height": 220, - "minWidth": 400, - "minHeight": 200, - "transparent": true, - "decorations": false, - "shadow": false - }], + "windows": [ + { + "title": "VRCT", + "center": true, + "width": 450, + "height": 220, + "minWidth": 400, + "minHeight": 200, + "transparent": true, + "decorations": false, + "shadow": false + } + ], "security": { "csp": null, - "capabilities": ["default", "vrct-capability"] + "capabilities": [ + "default", + "vrct-capability" + ] } }, "bundle": { @@ -56,4 +61,4 @@ } } } -} +} \ No newline at end of file diff --git a/src-ui/logics/_useBackendErrorHandling.js b/src-ui/logics/_useBackendErrorHandling.js index f224686c..fce938f3 100644 --- a/src-ui/logics/_useBackendErrorHandling.js +++ b/src-ui/logics/_useBackendErrorHandling.js @@ -2,6 +2,7 @@ import { useI18n } from "@useI18n"; import { useNotificationStatus, + useLLMConnection, } from "@logics_common"; import { @@ -35,7 +36,29 @@ export const _useBackendErrorHandling = () => { const { updateTranslationStatus, updateTranscriptionSendStatus, updateTranscriptionReceiveStatus } = useMainFunction(); - const { updateDeepLAuthKey } = useTranslation(); + const { + updateDeepLAuthKey, + + updatePlamoAuthKey, + updateSelectedPlamoModel, + + updateGeminiAuthKey, + updateSelectedGeminiModel, + + updateOpenAIAuthKey, + updateSelectedOpenAIModel, + + updateGroqAuthKey, + updateSelectedGroqModel, + + updateOpenRouterAuthKey, + updateSelectedOpenRouterModel, + + updateLMStudioURL, + updateSelectedLMStudioModel, + + updateSelectedOllamaModel, + } = useTranslation(); const { updateEnableVrcMicMuteSync } = useOthers(); @@ -46,6 +69,11 @@ export const _useBackendErrorHandling = () => { updateWebsocketPort, } = useAdvancedSettings(); + const { + updateIsOllamaConnected, + updateIsLMStudioConnected, + } = useLLMConnection(); + const errorHandling_Backend = ({message, data, endpoint, result}) => { switch (endpoint) { case "/run/error_device": @@ -93,7 +121,7 @@ export const _useBackendErrorHandling = () => { } return; - case "/run/enable_transcription_send": + case "/run/enable_transcription_receive": if (message === "Transcription receive disabled due to VRAM overflow") { updateTranscriptionReceiveStatus(data); showNotification_Error("Transcription receive disabled due to VRAM overflow"); @@ -129,6 +157,151 @@ export const _useBackendErrorHandling = () => { } return; + case "/set/data/plamo_auth_key": + if (message === "Plamo auth key length is not correct") { + updatePlamoAuthKey(data); + showNotification_Error(message, { category_id: "plamo_auth_key" }); + } else if (message === "Authentication failure of plamo auth key") { + updatePlamoAuthKey(data); + showNotification_Error(message, { category_id: "plamo_auth_key" }); + } else { + updatePlamoAuthKey(data); + showNotification_Error(message, { category_id: "plamo_auth_key" }); + } + return; + + case "/set/data/selected_plamo_model": + if (message === "Plamo model is not valid") { + updateSelectedPlamoModel(data); + showNotification_Error(message, { category_id: "selected_plamo_model" }); + } else { + updateSelectedPlamoModel(data); + showNotification_Error(message, { category_id: "selected_plamo_model" }); + } + return; + + case "/set/data/gemini_auth_key": + if (message === "Gemini auth key length is not correct") { + updateGeminiAuthKey(data); + showNotification_Error(message, { category_id: "gemini_auth_key" }); + } else if (message === "Authentication failure of gemini auth key") { + updateGeminiAuthKey(data); + showNotification_Error(message, { category_id: "gemini_auth_key" }); + } else { + updateGeminiAuthKey(data); + showNotification_Error(message, { category_id: "gemini_auth_key" }); + } + return; + + case "/set/data/selected_gemini_model": + if (message === "Gemini model is not valid") { + updateSelectedGeminiModel(data); + showNotification_Error(message, { category_id: "selected_gemini_model" }); + } else { + updateSelectedGeminiModel(data); + showNotification_Error(message, { category_id: "selected_gemini_model" }); + } + return; + + case "/set/data/openai_auth_key": + if (message === "OpenAI auth key is not valid") { + updateOpenAIAuthKey(data); + showNotification_Error(message, { category_id: "openai_auth_key" }); + } else if (message === "Authentication failure of OpenAI auth key") { + updateOpenAIAuthKey(data); + showNotification_Error(message, { category_id: "openai_auth_key" }); + } else { + updateOpenAIAuthKey(data); + showNotification_Error(message, { category_id: "openai_auth_key" }); + } + return; + + case "/set/data/selected_openai_model": + if (message === "OpenAI model is not valid") { + updateSelectedOpenAIModel(data); + showNotification_Error(message, { category_id: "selected_openai_model" }); + } else { + updateSelectedOpenAIModel(data); + showNotification_Error(message, { category_id: "selected_openai_model" }); + } + return; + + case "/set/data/groq_auth_key": + if (message === "Groq auth key is not valid") { + updateGroqAuthKey(data); + showNotification_Error(message, { category_id: "groq_auth_key" }); + } else if (message === "Authentication failure of Groq auth key") { + updateGroqAuthKey(data); + showNotification_Error(message, { category_id: "groq_auth_key" }); + } else { + updateGroqAuthKey(data); + showNotification_Error(message, { category_id: "groq_auth_key" }); + } + return; + + case "/set/data/selected_groq_model": + if (message === "Groq model is not valid") { + updateSelectedGroqModel(data); + showNotification_Error(message, { category_id: "selected_groq_model" }); + } else { + updateSelectedGroqModel(data); + showNotification_Error(message, { category_id: "selected_groq_model" }); + } + return; + + case "/set/data/openrouter_auth_key": + if (message === "OpenRouter auth key is not valid") { + updateOpenRouterAuthKey(data); + showNotification_Error(message, { category_id: "openrouter_auth_key" }); + } else if (message === "Authentication failure of OpenRouter auth key") { + updateOpenRouterAuthKey(data); + showNotification_Error(message, { category_id: "openrouter_auth_key" }); + } else { + updateOpenRouterAuthKey(data); + showNotification_Error(message, { category_id: "openrouter_auth_key" }); + } + return; + + case "/set/data/selected_openrouter_model": + if (message === "OpenRouter model is not valid") { + updateSelectedOpenRouterModel(data); + showNotification_Error(message, { category_id: "selected_openrouter_model" }); + } else { + updateSelectedOpenRouterModel(data); + showNotification_Error(message, { category_id: "selected_openrouter_model" }); + } + return; + + case "/set/data/lmstudio_url": + if (message === "LMStudio URL is not valid") { + updateLMStudioURL(data); + showNotification_Error(message, { category_id: "lmstudio_url" }); + } else { + updateLMStudioURL(data); + showNotification_Error(message, { category_id: "lmstudio_url" }); + } + return; + + case "/set/data/selected_lmstudio_model": + if (message === "LMStudio model is not valid") { + updateSelectedLMStudioModel(data); + showNotification_Error(message, { category_id: "selected_lmstudio_model" }); + } else { + updateSelectedLMStudioModel(data); + showNotification_Error(message, { category_id: "selected_lmstudio_model" }); + } + return; + + case "/set/data/selected_ollama_model": + if (message === "ollama model is not valid") { + updateSelectedOllamaModel(data); + showNotification_Error(message, { category_id: "selected_ollama_model" }); + } else { + updateSelectedOllamaModel(data); + showNotification_Error(message, { category_id: "selected_ollama_model" }); + } + return; + case "/set/data/mic_record_timeout": if (message === "Mic record timeout value is out of range") { updateMicRecordTimeout(data); @@ -221,8 +394,31 @@ export const _useBackendErrorHandling = () => { } return; + case "/run/lmstudio_connection": + if (message === "Cannot connect to LMStudio server") { + updateIsLMStudioConnected(data); + showNotification_Error(message, { category_id: "lmstudio_connection" }); + } else { + updateIsLMStudioConnected(data); + showNotification_Error(message, { category_id: "lmstudio_connection" }); + } + return; + + case "/run/ollama_connection": + if (message === "Cannot connect to ollama server") { + updateIsOllamaConnected(data); + showNotification_Error(message, { category_id: "ollama_connection" }); + } else { + updateIsOllamaConnected(data); + showNotification_Error(message, { category_id: "ollama_connection" }); + } + return; + default: console.error(`Invalid endpoint or message: ${endpoint}\nmessage: ${message}\nresult: ${JSON.stringify(result)}`); + showNotification_Error( + `An error occurred. Please contact the developers and restart VRCT. Error: Invalid endpoint or message: ${endpoint}\nmessage: ${message}\nresult: ${JSON.stringify(result)}`, { hide_duration: null } + ); return; } diff --git a/src-ui/logics/common/index.js b/src-ui/logics/common/index.js index c03d13eb..5975f52f 100644 --- a/src-ui/logics/common/index.js +++ b/src-ui/logics/common/index.js @@ -12,5 +12,7 @@ export { useUpdateSoftware } from "./useUpdateSoftware"; export { useVolume } from "./useVolume"; export { useHandleNetworkConnection } from "./useHandleNetworkConnection"; export { useHandleOscQuery } from "./useHandleOscQuery"; +export { useIsOscAvailable } from "./useIsOscAvailable"; export { useIsVrctAvailable } from "./useIsVrctAvailable"; -export { useFetch } from "./useFetch"; \ No newline at end of file +export { useFetch } from "./useFetch"; +export { useLLMConnection } from "./useLLMConnection"; \ No newline at end of file diff --git a/src-ui/logics/common/useHandleOscQuery.js b/src-ui/logics/common/useHandleOscQuery.js index f952aaac..45b03b96 100644 --- a/src-ui/logics/common/useHandleOscQuery.js +++ b/src-ui/logics/common/useHandleOscQuery.js @@ -1,45 +1,36 @@ import { useI18n } from "@useI18n"; -import { useNotificationStatus } from "@logics_common"; -import { useOthers } from "@logics_configs"; +import { + useNotificationStatus, + useIsOscAvailable, +} from "@logics_common"; export const useHandleOscQuery = () => { const { t } = useI18n(); const { showNotification_Warning } = useNotificationStatus(); - const { updateEnableVrcMicMuteSync } = useOthers(); + const { updateIsOscAvailable } = useIsOscAvailable(); const handleOscQuery = (payload) => { const is_osc_query_enabled = payload.data; const disabled_functions = payload.disabled_functions; + // OSC無効になるのは、OSC IP Addressが127.0.0.1、localhost以外の場合で発生。 if (is_osc_query_enabled) { - updateEnableVrcMicMuteSync(prev => ({ - ...prev.data, - is_available: true, - })); - return; - } + updateIsOscAvailable(true); - if (!disabled_functions.length) { - updateEnableVrcMicMuteSync(prev => ({ - ...prev.data, - is_available: false, - })); - return; - } + } else { // OSC自体は無効だが、無効になった機能がない場合。 + updateIsOscAvailable(false); - const items_label = disabled_functions - .filter(fn => fn === "vrc_mic_mute_sync") - .map(() => `- ${t("config_page.others.vrc_mic_mute_sync.label")}`) - .join("\n"); + if (disabled_functions.length > 0) { // 無効になった機能がある場合は通知。 + const items_label = disabled_functions + .filter(fn => fn === "vrc_mic_mute_sync") + .map(() => `- ${t("config_page.others.vrc_mic_mute_sync.label")}`) + .join("\n"); - updateEnableVrcMicMuteSync({ - is_enabled: false, - is_available: false, - }); - - if (items_label) { - const message = `${t("common_warning.unable_to_use_osc_query")}\n${items_label}`; - showNotification_Warning(message, { hide_duration: 10000 }); + if (items_label) { + const message = `${t("common_warning.unable_to_use_osc_query")}\n${items_label}`; + showNotification_Warning(message, { hide_duration: 10000 }); + } + } } }; diff --git a/src-ui/logics/common/useIsOscAvailable.js b/src-ui/logics/common/useIsOscAvailable.js new file mode 100644 index 00000000..ffbb96c9 --- /dev/null +++ b/src-ui/logics/common/useIsOscAvailable.js @@ -0,0 +1,10 @@ +import { useStore_IsOscAvailable } from "@store"; + +export const useIsOscAvailable = () => { + const { currentIsOscAvailable, updateIsOscAvailable } = useStore_IsOscAvailable(); + + return { + currentIsOscAvailable, + updateIsOscAvailable, + }; +}; \ No newline at end of file diff --git a/src-ui/logics/common/useLLMConnection.js b/src-ui/logics/common/useLLMConnection.js new file mode 100644 index 00000000..70bd350a --- /dev/null +++ b/src-ui/logics/common/useLLMConnection.js @@ -0,0 +1,47 @@ +import { useStdoutToPython } from "@useStdoutToPython"; +import { + useStore_IsLMStudioConnected, + useStore_IsOllamaConnected, +} from "@store"; + +export const useLLMConnection = () => { + const { asyncStdoutToPython } = useStdoutToPython(); + const { + currentIsLMStudioConnected, + updateIsLMStudioConnected, + pendingIsLMStudioConnected, + } = useStore_IsLMStudioConnected(); + const { + currentIsOllamaConnected, + updateIsOllamaConnected, + pendingIsOllamaConnected, + } = useStore_IsOllamaConnected(); + + const checkConnection_LMStudio = () => { + pendingIsLMStudioConnected(); + asyncStdoutToPython("/run/lmstudio_connection"); + }; + const setConnectionStatus_LMStudio = (is_connected) => { + updateIsLMStudioConnected(is_connected); + }; + + const checkConnection_Ollama = () => { + pendingIsOllamaConnected(); + asyncStdoutToPython("/run/ollama_connection"); + }; + const setConnectionStatus_Ollama = (is_connected) => { + updateIsOllamaConnected(is_connected); + }; + + return { + currentIsLMStudioConnected, + updateIsLMStudioConnected, + setConnectionStatus_LMStudio, + checkConnection_LMStudio, + + currentIsOllamaConnected, + updateIsOllamaConnected, + setConnectionStatus_Ollama, + checkConnection_Ollama, + }; +}; \ No newline at end of file diff --git a/src-ui/logics/configs/config_page_setter/ui_config_setter.js b/src-ui/logics/configs/config_page_setter/ui_config_setter.js index f3790a6c..0ffb7a89 100644 --- a/src-ui/logics/configs/config_page_setter/ui_config_setter.js +++ b/src-ui/logics/configs/config_page_setter/ui_config_setter.js @@ -314,6 +314,62 @@ export const SETTINGS_ARRAY = [ add_endpoint_run_array: ["from_backend"], base_endpoint_name: "selected_openai_model", }, + // Groq + { + Category: "Translation", + Base_Name: "GroqAuthKey", + default_value: "", + ui_template_id: "input", + logics_template_id: "get_set_delete", + base_endpoint_name: "groq_auth_key", + }, + { + Category: "Translation", + Base_Name: "SelectableGroqModelList", + default_value: [], + ui_template_id: "list", + logics_template_id: "get_set", + add_endpoint_run_array: ["from_backend"], + base_endpoint_name: "selectable_groq_model_list", + response_transform: "arrayToObject", + }, + { + Category: "Translation", + Base_Name: "SelectedGroqModel", + default_value: "", + ui_template_id: "select", + logics_template_id: "get_set", + add_endpoint_run_array: ["from_backend"], + base_endpoint_name: "selected_groq_model", + }, + // Open Router + { + Category: "Translation", + Base_Name: "OpenRouterAuthKey", + default_value: "", + ui_template_id: "input", + logics_template_id: "get_set_delete", + base_endpoint_name: "openrouter_auth_key", + }, + { + Category: "Translation", + Base_Name: "SelectableOpenRouterModelList", + default_value: [], + ui_template_id: "list", + logics_template_id: "get_set", + add_endpoint_run_array: ["from_backend"], + base_endpoint_name: "selectable_openrouter_model_list", + response_transform: "arrayToObject", + }, + { + Category: "Translation", + Base_Name: "SelectedOpenRouterModel", + default_value: "", + ui_template_id: "select", + logics_template_id: "get_set", + add_endpoint_run_array: ["from_backend"], + base_endpoint_name: "selected_openrouter_model", + }, // LM Studio { Category: "Translation", diff --git a/src-ui/logics/store.js b/src-ui/logics/store.js index 74e8f6da..abca4809 100644 --- a/src-ui/logics/store.js +++ b/src-ui/logics/store.js @@ -146,6 +146,7 @@ export const registerMany = (settingsArray = []) => { // Common export const { atomInstance: Atom_IsBackendReady, useHook: useStore_IsBackendReady } = createAtomWithHook(false, "IsBackendReady"); export const { atomInstance: Atom_IsVrctAvailable, useHook: useStore_IsVrctAvailable } = createAtomWithHook(true, "IsVrctAvailable"); +export const { atomInstance: Atom_IsOscAvailable, useHook: useStore_IsOscAvailable } = createAtomWithHook(true, "IsOscAvailable"); export const { atomInstance: Atom_ComputeMode, useHook: useStore_ComputeMode } = createAtomWithHook("", "ComputeMode"); export const { atomInstance: Atom_IsOpenedConfigPage, useHook: useStore_IsOpenedConfigPage } = createAtomWithHook(false, "IsOpenedConfigPage"); export const { atomInstance: Atom_MainFunctionsStateMemory, useHook: useStore_MainFunctionsStateMemory } = createAtomWithHook({ @@ -166,6 +167,8 @@ export const { atomInstance: Atom_NotificationStatus, useHook: useStore_Notifica key: 0, message: "", }, "NotificationStatus"); +export const { atomInstance: Atom_IsLMStudioConnected, useHook: useStore_IsLMStudioConnected } = createAtomWithHook(false, "IsLMStudioConnected"); +export const { atomInstance: Atom_IsOllamaConnected, useHook: useStore_IsOllamaConnected } = createAtomWithHook(false, "IsOllamaConnected"); // Main Page // Common diff --git a/src-ui/logics/ui_configs.js b/src-ui/logics/ui_configs.js index 191693c8..7c9b1d02 100644 --- a/src-ui/logics/ui_configs.js +++ b/src-ui/logics/ui_configs.js @@ -101,12 +101,19 @@ export const getPluginsList = () => { if (IS_PLUGIN_PATH_DEV_MODE || IS_PLUGIN_LIST_URL_DEV_MODE) console.warn("ui_configs IS_PLUGIN_PATH_DEV_MODE or IS_PLUGIN_LIST_URL_DEV_MODE is true. Turn to 'false' when it's production environment."); export const translator_status = [ - { id: "DeepL", label: "DeepL", is_available: false }, - { id: "DeepL_API", label: `DeepL API`, is_available: false }, + { id: "CTranslate2", label: `AI\nCTranslate2`, is_available: false, is_default: true }, { id: "Google", label: "Google", is_available: false }, { id: "Bing", label: "Bing", is_available: false }, { id: "Papago", label: "Papago", is_available: false }, - { id: "CTranslate2", label: `AI\nCTranslate2`, is_available: false, is_default: true }, + { id: "DeepL", label: "DeepL", is_available: false }, + { id: "DeepL_API", label: `DeepL API`, is_available: false }, + { id: "Plamo_API", label: `Plamo API`, is_available: false }, + { id: "Gemini_API", label: `Gemini API`, is_available: false }, + { id: "OpenAI_API", label: `OpenAI API`, is_available: false }, + { id: "Groq_API", label: `Groq API`, is_available: false }, + { id: "OpenRouter_API", label: `OpenRouter API`, is_available: false }, + { id: "LMStudio", label: `LMStudio`, is_available: false }, + { id: "Ollama", label: `Ollama`, is_available: false }, ]; export const ctranslate2_weight_type_status = [ @@ -130,6 +137,33 @@ export const whisper_weight_type_status = [ export const deepl_auth_key_url = "https://www.deepl.com/ja/your-account/keys"; +export const plamo_auth_key_url = "https://plamo.preferredai.jp/api"; +export const gemini_auth_key_url = "https://aistudio.google.com/api-keys"; +export const openai_auth_key_url = "https://platform.openai.com/api-keys"; +export const groq_auth_key_url = "https://console.groq.com/keys"; +export const openrouter_auth_key_url = "https://openrouter.ai/keys"; + + + +export const vrct_document_home_url = "https://misyaguziya.github.io/VRCT-Docs"; +export const vrct_document_url_chunk_faq = "docs/faq"; +export const vrct_document_url_chunk_ui_guide = "docs/ui-guide"; + +export const generateLocalizedDocumentUrl = (lang_code = "en") => { + const supported_languages = ["en", "ja"]; + + if (supported_languages.includes(lang_code) === false) { + lang_code = "en"; + } + + const lang_path = (lang_code === "en") ? "" : `/${lang_code}`; + + return { + vrct_document_home_url: `${vrct_document_home_url}`, + vrct_document_faq_url: `${vrct_document_home_url}${lang_path}/${vrct_document_url_chunk_faq}`, + vrct_document_ui_guide_url: `${vrct_document_home_url}${lang_path}/${vrct_document_url_chunk_ui_guide}`, + }; +}; export const supporters_data_url = "https://shiinasakamoto.github.io/vrct_supporters/assets/supporters/data.json"; diff --git a/src-ui/logics/useReceiveRoutes.js b/src-ui/logics/useReceiveRoutes.js index e9936936..164cce7e 100644 --- a/src-ui/logics/useReceiveRoutes.js +++ b/src-ui/logics/useReceiveRoutes.js @@ -20,6 +20,12 @@ export const STATIC_ROUTE_META_LIST = [ { endpoint: "/run/open_filepath_logs", ns: common, hook_name: "useOpenFolder", method_name: "openedFolder_MessageLogs" }, { endpoint: "/run/open_filepath_config_file", ns: common, hook_name: "useOpenFolder", method_name: "openedFolder_ConfigFile" }, + { endpoint: "/get/data/connected_lmstudio", ns: common, hook_name: "useLLMConnection", method_name: "setConnectionStatus_LMStudio" }, + { endpoint: "/run/lmstudio_connection", ns: common, hook_name: "useLLMConnection", method_name: "setConnectionStatus_LMStudio" }, + + { endpoint: "/get/data/connected_ollama", ns: common, hook_name: "useLLMConnection", method_name: "setConnectionStatus_Ollama" }, + { endpoint: "/run/ollama_connection", ns: common, hook_name: "useLLMConnection", method_name: "setConnectionStatus_Ollama" }, + // Software Version { endpoint: "/get/data/version", ns: common, hook_name: "useSoftwareVersion", method_name: "updateSoftwareVersion" }, // Latest Software Version Info diff --git a/src-ui/views/app/_app_controllers/StartPythonController.jsx b/src-ui/views/app/_app_controllers/StartPythonController.jsx index 55824bc8..d2781abf 100644 --- a/src-ui/views/app/_app_controllers/StartPythonController.jsx +++ b/src-ui/views/app/_app_controllers/StartPythonController.jsx @@ -49,7 +49,8 @@ const useStartPython = () => { }); command.stderr.on("data", line => { showNotification_Error( - `An error occurred. Please restart VRCT or contact the developers. The last line:${JSON.stringify(line)}`, { hide_duration: null }); + `An error occurred. Please restart VRCT or contact the developers. The last line:${JSON.stringify(line)}`, { hide_duration: null } + ); console.error("stderr", line); }); const backend_subprocess = await command.spawn(); diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/_atoms/_download_button/_DownloadButton.module.scss b/src-ui/views/app/config_page/setting_section/setting_box/_components/_atoms/_download_button/_DownloadButton.module.scss index 752dca38..d47ecc6c 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_components/_atoms/_download_button/_DownloadButton.module.scss +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/_atoms/_download_button/_DownloadButton.module.scss @@ -6,6 +6,7 @@ justify-content: center; width: 100%; max-width: 8rem; + flex-shrink: 0; } .download_button { diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/_atoms/_open_webpage_button/_OpenWebpageButton.module.scss b/src-ui/views/app/config_page/setting_section/setting_box/_components/_atoms/_open_webpage_button/_OpenWebpageButton.module.scss index fd300952..9547963f 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_components/_atoms/_open_webpage_button/_OpenWebpageButton.module.scss +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/_atoms/_open_webpage_button/_OpenWebpageButton.module.scss @@ -2,10 +2,11 @@ display: flex; justify-content: center; align-items: center; + width: fit-content; } .open_webpage_button { - padding: 0.6rem 2.8rem; + padding: 0.6rem 1.2rem; display: flex; gap: 1rem; justify-content: center; diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/auth_key/AuthKey.jsx b/src-ui/views/app/config_page/setting_section/setting_box/_components/auth_key/AuthKey.jsx index 85ae9928..4cddbdeb 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_components/auth_key/AuthKey.jsx +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/auth_key/AuthKey.jsx @@ -38,7 +38,7 @@ export const AuthKey = (props) => { return (
- <_Entry ref={entryRef} width="30rem" onChange={onchangeEntryAuthKey} ui_variable={props.variable} is_disabled={is_disabled}/> + <_Entry ref={entryRef} width="24rem" onChange={onchangeEntryAuthKey} ui_variable={props.variable} is_disabled={is_disabled}/> +
+ ); +}; \ No newline at end of file diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/connection_check_button/ConnectionCheckButton.module.scss b/src-ui/views/app/config_page/setting_section/setting_box/_components/connection_check_button/ConnectionCheckButton.module.scss new file mode 100644 index 00000000..6b1bd5d0 --- /dev/null +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/connection_check_button/ConnectionCheckButton.module.scss @@ -0,0 +1,26 @@ +.button_wrapper { + padding: 1.6rem; + border-radius: 0.4rem; + &:hover { + background-color: var(--dark_825_color); + } + &:active { + background-color: var(--dark_900_color); + } +} + +.status_label { + font-size: 1.2rem; + color: var(--dark_200_color); + margin-bottom: 0.8rem; +} + +.button_label { + font-size: 1.4rem; + color: var(--dark_100_color); +} + +.button_svg { + width: 2.4rem; + color: var(--dark_400_color); +} \ No newline at end of file diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/dropdown_menu/DropdownMenu.module.scss b/src-ui/views/app/config_page/setting_section/setting_box/_components/dropdown_menu/DropdownMenu.module.scss index cbb1723f..2e12004b 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_components/dropdown_menu/DropdownMenu.module.scss +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/dropdown_menu/DropdownMenu.module.scss @@ -4,6 +4,7 @@ display: flex; flex-direction: row; gap: 2.8rem; + flex-shrink: 0; } .each_dropdown_menu_wrapper { @@ -12,6 +13,7 @@ gap: 0.6rem; white-space: nowrap; max-width: 24rem; + flex-shrink: 0; &.is_disabled { pointer-events: none; } diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/index.js b/src-ui/views/app/config_page/setting_section/setting_box/_components/index.js index 2e7b54ca..d28f063d 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_components/index.js +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/index.js @@ -13,4 +13,5 @@ export { SwitchBox } from "./switch_box/SwitchBox"; export { ThresholdComponent } from "./threshold_component/ThresholdComponent"; export { WordFilter, WordFilterListToggleComponent } from "./word_filter/WordFilter"; export { DownloadModels } from "./download_models/DownloadModels"; -export { MessageFormat } from "./message_format/MessageFormat"; \ No newline at end of file +export { MessageFormat } from "./message_format/MessageFormat"; +export { ConnectionCheckButton } from "./connection_check_button/ConnectionCheckButton"; \ No newline at end of file diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/label_component/LabelComponent.jsx b/src-ui/views/app/config_page/setting_section/setting_box/_components/label_component/LabelComponent.jsx index ffcb9a89..99077bd0 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_components/label_component/LabelComponent.jsx +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/label_component/LabelComponent.jsx @@ -1,5 +1,6 @@ import styles from "./LabelComponent.module.scss"; import { _OpenWebpageButton } from "../_atoms/_open_webpage_button/_OpenWebpageButton"; +import WarningSvg from "@images/warning.svg?react"; export const LabelComponent = (props) => { return ( @@ -9,6 +10,17 @@ export const LabelComponent = (props) => { ?

{props.desc}

: null } + {props.add_warnings && Array.isArray(props.add_warnings) && props.add_warnings.length > 0 && ( +
+ {props.add_warnings.map((w, i) => ( +
+ +

{w.label}

+
+ ))} +
+ )} + {props.webpage_url && <_OpenWebpageButton webpage_url={props.webpage_url} open_webpage_label={props.open_webpage_label} /> }
); diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/label_component/LabelComponent.module.scss b/src-ui/views/app/config_page/setting_section/setting_box/_components/label_component/LabelComponent.module.scss index a2b6e999..7a266f1f 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_components/label_component/LabelComponent.module.scss +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/label_component/LabelComponent.module.scss @@ -2,7 +2,8 @@ display: flex; flex-direction: column; gap: 0.4rem; - // flex-shrink: 0; + max-width: 38rem; + overflow-wrap: break-word; } .label { @@ -16,6 +17,28 @@ font-size: 1.4rem; font-weight: 300; color: var(--dark_500_color); - max-width: 38rem; overflow-wrap: break-word; +} + +.warnings_section { + font-weight: 300; + color: var(--dark_500_color); +} + +.warning_item { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.warning_label { + font-size: 1.2rem; + color: var(--warning_color); + overflow-wrap: break-word; +} + +.warning_svg { + width: 1.4rem; + color: var(--warning_color); + flex-shrink: 0; } \ No newline at end of file diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_components/message_format/MessageFormat.jsx b/src-ui/views/app/config_page/setting_section/setting_box/_components/message_format/MessageFormat.jsx index 10fc3ab8..35f072e9 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_components/message_format/MessageFormat.jsx +++ b/src-ui/views/app/config_page/setting_section/setting_box/_components/message_format/MessageFormat.jsx @@ -10,6 +10,7 @@ import { import { useAppearance } from "@logics_configs"; import { ui_configs } from "@ui_configs"; import { ResetButton } from "@common_components"; +import { useState, useEffect, useRef } from "react"; const ENTRY_WIDTH = "8rem"; @@ -199,23 +200,51 @@ const InputComponent = ({id, variable, setFunction, format_id }) => { return replaced; }; + const [local_var, setLocalVar] = useState(variable); + const debounce_ref = useRef(null); + + useEffect(() => { + setLocalVar(variable); + }, [variable]); + + useEffect(() => { + return () => { + if (debounce_ref.current) { + clearTimeout(debounce_ref.current); + debounce_ref.current = null; + } + }; + }, []); + + const scheduleUpdate = (new_var) => { + if (debounce_ref.current) clearTimeout(debounce_ref.current); + debounce_ref.current = setTimeout(() => { + setFunction(new_var); + debounce_ref.current = null; + }, 500); + }; + const handleChange = (parent_key, child_key) => (e) => { - const rawValue = e.target.value; - const parsedValue = replaceValue(rawValue); + const raw_value = e.target.value; + const parsed_value = replaceValue(raw_value); if (child_key !== undefined) { - setFunction({ - ...variable, + const new_var = { + ...local_var, [parent_key]: { - ...variable[parent_key], - [child_key]: parsedValue - } - }); + ...local_var[parent_key], + [child_key]: parsed_value, + }, + }; + setLocalVar(new_var); + scheduleUpdate(new_var); } else { - setFunction({ - ...variable, - [parent_key]: parsedValue - }); + const new_var = { + ...local_var, + [parent_key]: parsed_value, + }; + setLocalVar(new_var); + scheduleUpdate(new_var); } }; @@ -229,16 +258,16 @@ const InputComponent = ({id, variable, setFunction, format_id }) => { }; const resetFunction = () => { - if (format_id === "send") { - setFunction(ui_configs.send_message_format_parts); - } else if (format_id === "received") { - setFunction(ui_configs.received_message_format_parts); - } + const new_val = format_id === "send" ? ui_configs.send_message_format_parts : ui_configs.received_message_format_parts; + setLocalVar(new_val); + setFunction(new_val); }; const SwapButton = ({ variable, setFunction }) => { const swapMessageAndTranslate = () => { - setFunction({ ...variable, translation_first: !variable.translation_first }); + const new_var = { ...variable, translation_first: !variable.translation_first }; + setLocalVar(new_var); + setFunction(new_var); }; return ( @@ -255,38 +284,38 @@ const InputComponent = ({id, variable, setFunction, format_id }) => {

{label_title}

- +
- { !variable.translation_first ? + { !local_var.translation_first ?
- <_Entry ui_variable={toUiValue(variable.message.prefix)} width={ENTRY_WIDTH} onChange={handleChange("message", "prefix")} /> + <_Entry ui_variable={toUiValue(local_var.message.prefix)} width={ENTRY_WIDTH} onChange={handleChange("message", "prefix")} />

{LABEL_ORIGINAL}

- <_Entry ui_variable={toUiValue(variable.message.suffix)} width={ENTRY_WIDTH} onChange={handleChange("message", "suffix")} /> + <_Entry ui_variable={toUiValue(local_var.message.suffix)} width={ENTRY_WIDTH} onChange={handleChange("message", "suffix")} />
- <_Entry ui_variable={toUiValue(variable.separator)} width={ENTRY_WIDTH} onChange={handleChange("separator")} /> + <_Entry ui_variable={toUiValue(local_var.separator)} width={ENTRY_WIDTH} onChange={handleChange("separator")} />
- <_Entry ui_variable={toUiValue(variable.translation.prefix)} width={ENTRY_WIDTH} onChange={handleChange("translation", "prefix")} /> + <_Entry ui_variable={toUiValue(local_var.translation.prefix)} width={ENTRY_WIDTH} onChange={handleChange("translation", "prefix")} />

{LABEL_TRANSLATED}

- <_Entry ui_variable={toUiValue(variable.translation.suffix)} width={ENTRY_WIDTH} onChange={handleChange("translation", "suffix")} /> + <_Entry ui_variable={toUiValue(local_var.translation.suffix)} width={ENTRY_WIDTH} onChange={handleChange("translation", "suffix")} />
:
- <_Entry ui_variable={toUiValue(variable.translation.prefix)} width={ENTRY_WIDTH} onChange={handleChange("translation", "prefix")} /> + <_Entry ui_variable={toUiValue(local_var.translation.prefix)} width={ENTRY_WIDTH} onChange={handleChange("translation", "prefix")} />

{LABEL_TRANSLATED}

- <_Entry ui_variable={toUiValue(variable.translation.suffix)} width={ENTRY_WIDTH} onChange={handleChange("translation", "suffix")} /> + <_Entry ui_variable={toUiValue(local_var.translation.suffix)} width={ENTRY_WIDTH} onChange={handleChange("translation", "suffix")} />
- <_Entry ui_variable={toUiValue(variable.separator)} width={ENTRY_WIDTH} onChange={handleChange("separator")} /> + <_Entry ui_variable={toUiValue(local_var.separator)} width={ENTRY_WIDTH} onChange={handleChange("separator")} />
- <_Entry ui_variable={toUiValue(variable.message.prefix)} width={ENTRY_WIDTH} onChange={handleChange("message", "prefix")} /> + <_Entry ui_variable={toUiValue(local_var.message.prefix)} width={ENTRY_WIDTH} onChange={handleChange("message", "prefix")} />

{LABEL_ORIGINAL}

- <_Entry ui_variable={toUiValue(variable.message.suffix)} width={ENTRY_WIDTH} onChange={handleChange("message", "suffix")} /> + <_Entry ui_variable={toUiValue(local_var.message.suffix)} width={ENTRY_WIDTH} onChange={handleChange("message", "suffix")} />
} @@ -295,7 +324,7 @@ const InputComponent = ({id, variable, setFunction, format_id }) => {

{LABEL_FOR_MULTI_TRANSLATION}

{LABEL_TRANSLATED}

- <_Entry ui_variable={toUiValue(variable.translation.separator)} width={ENTRY_WIDTH} onChange={handleChange("translation", "separator")} /> + <_Entry ui_variable={toUiValue(local_var.translation.separator)} width={ENTRY_WIDTH} onChange={handleChange("translation", "separator")} />

{LABEL_TRANSLATED}

diff --git a/src-ui/views/app/config_page/setting_section/setting_box/_templates/Templates.jsx b/src-ui/views/app/config_page/setting_section/setting_box/_templates/Templates.jsx index 0c0845e1..b4952c99 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/_templates/Templates.jsx +++ b/src-ui/views/app/config_page/setting_section/setting_box/_templates/Templates.jsx @@ -19,6 +19,7 @@ import { WordFilterListToggleComponent, DownloadModels, MessageFormat, + ConnectionCheckButton, } from "../_components"; import { Checkbox } from "@common_components"; @@ -181,6 +182,10 @@ export const DownloadModelsContainer = (props) => ( ); +export const ConnectionCheckButtonContainer = (props) => ( + +); + export const MessageFormatContainer = (props) => { return ( <> diff --git a/src-ui/views/app/config_page/setting_section/setting_box/about_vrct/AboutVrct.jsx b/src-ui/views/app/config_page/setting_section/setting_box/about_vrct/AboutVrct.jsx index a939ebbe..01e46cc3 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/about_vrct/AboutVrct.jsx +++ b/src-ui/views/app/config_page/setting_section/setting_box/about_vrct/AboutVrct.jsx @@ -31,6 +31,8 @@ import { useI18n } from "@useI18n"; import { useAppearance } from "@logics_configs"; import { PosterShowcaseContents } from "./poster_showcase_contents/PosterShowcaseContents"; +import { generateLocalizedDocumentUrl } from "@ui_configs"; + export const AboutVrct = () => { const { t } = useI18n(); const { currentUiLanguage } = useAppearance(); @@ -153,7 +155,7 @@ const about_vrct_links = { dev_shiina_x: { img: dev_x_icon, href: "https://twitter.com/Shiina_12siy" }, project_link_booth: { img: project_link_booth, href: "https://misyaguziya.booth.pm/items/5155325" }, - project_link_documents: { img: project_link_documents, href: "https://mzsoftware.notion.site/VRCT-Documents-be79b7a165f64442ad8f326d86c22246" }, + project_link_documents: { img: project_link_documents, href: generateLocalizedDocumentUrl().vrct_document_home_url }, project_link_vrct_github: { img: project_link_vrct_github, href: "https://github.com/misyaguziya/VRCT" }, project_link_contact_us: { img: project_link_contact_us, href: "https://docs.google.com/forms/d/e/1FAIpQLSei-xoydOY60ivXqhOjaTzNN8PiBQIDcNhzfy6cw2sjYkcg_g/viewform" }, diff --git a/src-ui/views/app/config_page/setting_section/setting_box/about_vrct/AboutVrct.module.scss b/src-ui/views/app/config_page/setting_section/setting_box/about_vrct/AboutVrct.module.scss index f9efc103..0d1e620c 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/about_vrct/AboutVrct.module.scss +++ b/src-ui/views/app/config_page/setting_section/setting_box/about_vrct/AboutVrct.module.scss @@ -174,7 +174,7 @@ $sns_left_pos: 0.8rem; display: flex; justify-content: center; align-items: start; - column-gap: 6rem; + column-gap: 4rem; row-gap: 1rem; flex-wrap: wrap; } diff --git a/src-ui/views/app/config_page/setting_section/setting_box/others/Others.jsx b/src-ui/views/app/config_page/setting_section/setting_box/others/Others.jsx index 3b32746a..0af65930 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/others/Others.jsx +++ b/src-ui/views/app/config_page/setting_section/setting_box/others/Others.jsx @@ -1,7 +1,11 @@ import { useI18n } from "@useI18n"; import styles from "./Others.module.scss"; -import { useOpenFolder } from "@logics_common"; +import { + useOpenFolder, + useIsOscAvailable, +} from "@logics_common"; + import { useOthers, } from "@logics_configs"; @@ -104,18 +108,22 @@ const AutoExportMessageLogsContainer = () => { export const VrcMicMuteSyncContainer = () => { const { t } = useI18n(); const { currentEnableVrcMicMuteSync, toggleEnableVrcMicMuteSync } = useOthers(); + const { currentIsOscAvailable } = useIsOscAvailable(); - const variable = { - state: currentEnableVrcMicMuteSync.state, - data: currentEnableVrcMicMuteSync.data.is_enabled, - }; + const add_warnings = []; + if (currentIsOscAvailable.data === false) { + add_warnings.push({ + label: t("config_page.common.warning_labels.unable_to_use_osc_query"), + }); + } return ( ); diff --git a/src-ui/views/app/config_page/setting_section/setting_box/translation/Translation.jsx b/src-ui/views/app/config_page/setting_section/setting_box/translation/Translation.jsx index 7ad243ff..684b7090 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/translation/Translation.jsx +++ b/src-ui/views/app/config_page/setting_section/setting_box/translation/Translation.jsx @@ -17,6 +17,7 @@ import { EntryWithSaveButtonContainer, RadioButtonContainer, DropdownMenuContainer, + ConnectionCheckButtonContainer, useOnMouseLeaveDropdownMenu, } from "../_templates/Templates"; @@ -25,9 +26,19 @@ import { DropdownMenu, MultiDropdownMenu, LabelComponent, + ConnectionCheckButton, } from "../_components"; -import { deepl_auth_key_url } from "@ui_configs"; +import { + deepl_auth_key_url, + plamo_auth_key_url, + gemini_auth_key_url, + openai_auth_key_url, + groq_auth_key_url, + openrouter_auth_key_url, +} from "@ui_configs"; + +import { useLLMConnection } from "@logics_common"; export const Translation = () => { return ( @@ -46,9 +57,17 @@ export const Translation = () => { + + + + + + + + ); @@ -245,7 +264,7 @@ const DeepLAuthKey_Box = () => { {translator: t("main_page.translator")} )} webpage_url={deepl_auth_key_url} - open_webpage_label={t("config_page.translation.deepl_auth_key.open_auth_key_webpage")} + open_webpage_label={t("config_page.common.open_auth_key_webpage")} variable={variable} state={currentDeepLAuthKey.state} onChangeFunction={onChangeFunction} @@ -269,10 +288,10 @@ const PlamoAuthKey_Box = () => { return ( <> { currentSelectedPlamoModel, setSelectedPlamoModel, + + currentPlamoAuthKey, } = useTranslation(); - if (currentSelectablePlamoModelList.data.length === 0) return null; const selectFunction = (selected_data) => { setSelectedPlamoModel(selected_data.selected_id); }; + + let selected_label = (!currentPlamoAuthKey.data && !currentSelectedPlamoModel.data) ? t("config_page.common.correct_auth_key_required") : currentSelectedPlamoModel.data; + + return ( ); }; @@ -325,10 +350,10 @@ const GeminiAuthKey_Box = () => { return ( <> { currentSelectedGeminiModel, setSelectedGeminiModel, + + currentGeminiAuthKey, } = useTranslation(); - if (currentSelectableGeminiModelList.data.length === 0) return null; const selectFunction = (selected_data) => { setSelectedGeminiModel(selected_data.selected_id); }; + let selected_label = (!currentGeminiAuthKey.data && !currentSelectedGeminiModel.data) + ? t("config_page.common.correct_auth_key_required") + : currentSelectedGeminiModel.data; + return ( ); }; @@ -380,10 +411,10 @@ const OpenAIAuthKey_Box = () => { return ( <> { currentSelectedOpenAIModel, setSelectedOpenAIModel, + + currentOpenAIAuthKey, } = useTranslation(); - if (currentSelectableOpenAIModelList.data.length === 0) return null; const selectFunction = (selected_data) => { setSelectedOpenAIModel(selected_data.selected_id); }; + let selected_label = (!currentOpenAIAuthKey.data && !currentSelectedOpenAIModel.data) + ? t("config_page.common.correct_auth_key_required") + : currentSelectedOpenAIModel.data; + return ( ); }; +const GroqAuthKey_Box = () => { + const { t } = useI18n(); + const { currentGroqAuthKey, setGroqAuthKey, deleteGroqAuthKey } = useTranslation(); + const { variable, onChangeFunction, saveFunction } = useSaveButtonLogic({ + variable: currentGroqAuthKey.data, + state: currentGroqAuthKey.state, + setFunction: setGroqAuthKey, + deleteFunction: deleteGroqAuthKey, + }); + + return ( + <> + + + ); +}; +const GroqModelContainer = () => { + const { t } = useI18n(); + const { + currentSelectableGroqModelList, + + currentSelectedGroqModel, + setSelectedGroqModel, + + currentGroqAuthKey, + } = useTranslation(); + + + const selectFunction = (selected_data) => { + setSelectedGroqModel(selected_data.selected_id); + }; + + let selected_label = (!currentGroqAuthKey.data && !currentSelectedGroqModel.data) + ? t("config_page.common.correct_auth_key_required") + : currentSelectedGroqModel.data; + + return ( + + ); +}; + + +const OpenRouterAuthKey_Box = () => { + const { t } = useI18n(); + const { currentOpenRouterAuthKey, setOpenRouterAuthKey, deleteOpenRouterAuthKey } = useTranslation(); + + const { variable, onChangeFunction, saveFunction } = useSaveButtonLogic({ + variable: currentOpenRouterAuthKey.data, + state: currentOpenRouterAuthKey.state, + setFunction: setOpenRouterAuthKey, + deleteFunction: deleteOpenRouterAuthKey, + }); + + return ( + <> + + + ); +}; +const OpenRouterModelContainer = () => { + const { t } = useI18n(); + const { + currentSelectableOpenRouterModelList, + + currentSelectedOpenRouterModel, + setSelectedOpenRouterModel, + + currentOpenRouterAuthKey, + } = useTranslation(); + + + const selectFunction = (selected_data) => { + setSelectedOpenRouterModel(selected_data.selected_id); + }; + + let selected_label = (!currentOpenRouterAuthKey.data && !currentSelectedOpenRouterModel.data) + ? t("config_page.common.correct_auth_key_required") + : currentSelectedOpenRouterModel.data; + + return ( + + ); +}; + +const LMStudioConnectionCheck_Box = () => { + const { t } = useI18n(); + const { currentIsLMStudioConnected, checkConnection_LMStudio } = useLLMConnection(); + + return ( + <> + + + ); +}; const LMStudioURL_Box = () => { const { t } = useI18n(); const { currentLMStudioURL, setLMStudioURL, deleteLMStudioURL } = useTranslation(); @@ -437,7 +611,8 @@ const LMStudioURL_Box = () => { <> { setSelectedLMStudioModel, } = useTranslation(); - if (currentSelectableLMStudioModelList.data.length === 0) return null; + const { currentIsLMStudioConnected } = useLLMConnection(); const selectFunction = (selected_data) => { setSelectedLMStudioModel(selected_data.selected_id); }; + let selected_label = (!currentIsLMStudioConnected.data && !currentSelectedLMStudioModel.data) + ? t("config_page.translation.select_lmstudio_model.connection_required") + : currentSelectedLMStudioModel.data; + return ( ); }; +const OllamaConnectionCheck_Box = () => { + const { t } = useI18n(); + const { currentIsOllamaConnected, checkConnection_Ollama } = useLLMConnection(); + + return ( + <> + + + ); +}; const OllamaModelContainer = () => { const { t } = useI18n(); const { @@ -484,20 +681,25 @@ const OllamaModelContainer = () => { setSelectedOllamaModel, } = useTranslation(); - if (currentSelectableOllamaModelList.data.length === 0) return null; + const { currentIsOllamaConnected } = useLLMConnection(); const selectFunction = (selected_data) => { setSelectedOllamaModel(selected_data.selected_id); }; + let selected_label = (!currentIsOllamaConnected.data && !currentSelectedOllamaModel.data) + ? t("config_page.translation.select_ollama_model.connection_required") + : currentSelectedOllamaModel.data; + return ( ); }; diff --git a/src-ui/views/app/config_page/sidebar_section/SidebarSection.jsx b/src-ui/views/app/config_page/sidebar_section/SidebarSection.jsx index b7d36272..4242f5b0 100644 --- a/src-ui/views/app/config_page/sidebar_section/SidebarSection.jsx +++ b/src-ui/views/app/config_page/sidebar_section/SidebarSection.jsx @@ -27,11 +27,15 @@ export const SidebarSection = () => { import clsx from "clsx"; import { useI18n } from "@useI18n"; -import { useStore_SelectedConfigTabId } from "@store"; +import { + useStore_SelectedConfigTabId, + useStore_IsBreakPoint, +} from "@store"; const Tab = (props) => { const { t } = useI18n(); const { updateSelectedConfigTabId, currentSelectedConfigTabId } = useStore_SelectedConfigTabId(); + const { currentIsBreakPoint } = useStore_IsBreakPoint(); const onclickFunction = () => { updateSelectedConfigTabId(props.tab_id); }; @@ -54,7 +58,9 @@ const Tab = (props) => { return (
-

{getLabel()}

+

{getLabel()}

); diff --git a/src-ui/views/app/config_page/sidebar_section/SidebarSection.module.scss b/src-ui/views/app/config_page/sidebar_section/SidebarSection.module.scss index 56977d6f..f5806d06 100644 --- a/src-ui/views/app/config_page/sidebar_section/SidebarSection.module.scss +++ b/src-ui/views/app/config_page/sidebar_section/SidebarSection.module.scss @@ -64,6 +64,9 @@ font-size: 1.6rem; // text-overflow: ellipsis; position: relative; + &.tab_text_small { + font-size: 1.4rem; + } } .crown_emoji { font-size: 1.6rem; diff --git a/src-ui/views/app/main_page/main_section/top_bar/right_side_components/RightSideComponents.jsx b/src-ui/views/app/main_page/main_section/top_bar/right_side_components/RightSideComponents.jsx index 6c1ff7d3..2cd87eb4 100644 --- a/src-ui/views/app/main_page/main_section/top_bar/right_side_components/RightSideComponents.jsx +++ b/src-ui/views/app/main_page/main_section/top_bar/right_side_components/RightSideComponents.jsx @@ -4,11 +4,22 @@ import RefreshSvg from "@images/refresh.svg?react"; import HelpSvg from "@images/help.svg?react"; import { useStore_OpenedQuickSetting } from "@store"; -import { useSoftwareVersion } from "@logics_common"; -import { useVr, useOthers } from "@logics_configs"; +import { + useSoftwareVersion, + useIsOscAvailable, +} from "@logics_common"; + +import { + useAppearance, + useVr, + useOthers, +} from "@logics_configs"; import { OpenQuickSettingButton } from "./_buttons/OpenQuickSettingButton"; +import { generateLocalizedDocumentUrl } from "@ui_configs"; + export const RightSideComponents = () => { + const { currentUiLanguage } = useAppearance(); return (
@@ -19,7 +30,7 @@ export const RightSideComponents = () => { @@ -70,6 +81,7 @@ const PluginsQuickSetting = () => { const OpenVrcMicMuteSyncQuickSetting = () => { const { t } = useI18n(); const { updateOpenedQuickSetting } = useStore_OpenedQuickSetting(); + const { currentIsOscAvailable } = useIsOscAvailable(); const { currentEnableVrcMicMuteSync } = useOthers(); const onClickFunction = () => { @@ -79,7 +91,8 @@ const OpenVrcMicMuteSyncQuickSetting = () => { return ( ); diff --git a/src-ui/views/app/main_page/main_section/top_bar/right_side_components/_buttons/OpenQuickSettingButton.jsx b/src-ui/views/app/main_page/main_section/top_bar/right_side_components/_buttons/OpenQuickSettingButton.jsx index c24f40f4..c2d84773 100644 --- a/src-ui/views/app/main_page/main_section/top_bar/right_side_components/_buttons/OpenQuickSettingButton.jsx +++ b/src-ui/views/app/main_page/main_section/top_bar/right_side_components/_buttons/OpenQuickSettingButton.jsx @@ -1,21 +1,31 @@ import { useI18n } from "@useI18n"; import clsx from "clsx"; import styles from "./OpenQuickSettingButton.module.scss"; +import WarningSvg from "@images/warning.svg?react"; export const OpenQuickSettingButton = (props) => { const { t } = useI18n(); const variable = (typeof props.variable === "boolean") ? props.variable : null; + const is_available = (typeof props.is_available === "boolean") ? props.is_available : true; + + const getIndicatorLabelClassName = (base_classnames = []) => { + return clsx(...base_classnames, is_available && styles.is_available); + }; + return (

{props.label}

{variable !== null && ( props.variable === true ? ( -

+

{t("main_page.state_text_enabled")} + {is_available === false && ( + + )}

) : ( -

+

{t("main_page.state_text_disabled")}

) diff --git a/src-ui/views/app/main_page/main_section/top_bar/right_side_components/_buttons/OpenQuickSettingButton.module.scss b/src-ui/views/app/main_page/main_section/top_bar/right_side_components/_buttons/OpenQuickSettingButton.module.scss index d46cbee0..92f741bb 100644 --- a/src-ui/views/app/main_page/main_section/top_bar/right_side_components/_buttons/OpenQuickSettingButton.module.scss +++ b/src-ui/views/app/main_page/main_section/top_bar/right_side_components/_buttons/OpenQuickSettingButton.module.scss @@ -26,10 +26,20 @@ .button_indicator_label { font-size: 1rem; - &.disabled { + &.is_disabled { color: var(--dark_600_color); } - &.enabled { + &.is_enabled { color: var(--primary_300_color); + &:not(.is_available) { + color: var(--warning_color); + } } +} + +.warning_svg { + margin-left: 0.4rem; + padding-bottom: 0.1rem; + width: 1.1rem; + color: var(--warning_color); } \ No newline at end of file diff --git a/src-ui/views/app/main_page/sidebar_section/language_settings/translator_selector_open_button/translator_selector/TranslatorSelector.module.scss b/src-ui/views/app/main_page/sidebar_section/language_settings/translator_selector_open_button/translator_selector/TranslatorSelector.module.scss index b73fef4c..50bc5d8d 100644 --- a/src-ui/views/app/main_page/sidebar_section/language_settings/translator_selector_open_button/translator_selector/TranslatorSelector.module.scss +++ b/src-ui/views/app/main_page/sidebar_section/language_settings/translator_selector_open_button/translator_selector/TranslatorSelector.module.scss @@ -8,6 +8,7 @@ display: flex; justify-content: center; align-items: center; + overflow-y: auto; } .relative_container { @@ -17,8 +18,7 @@ } .wrapper { - width: 100%; - height: 100%; + padding: 2rem 0; display: flex; flex-direction: column; justify-content: center; diff --git a/src-ui/views/app/others/splash_component/SplashComponent.jsx b/src-ui/views/app/others/splash_component/SplashComponent.jsx index a9ac4aeb..b34ab288 100644 --- a/src-ui/views/app/others/splash_component/SplashComponent.jsx +++ b/src-ui/views/app/others/splash_component/SplashComponent.jsx @@ -6,6 +6,7 @@ import MegaphoneSvg from "@images/megaphone.svg?react"; import XMarkSvg from "@images/cancel.svg?react"; import { useWindow } from "@logics_common"; import clsx from "clsx"; +import { generateLocalizedDocumentUrl } from "@ui_configs"; export const SplashComponent = () => { return ( @@ -56,7 +57,7 @@ const AnnouncementsContainer = () => { [styles.is_shown]: is_shown, [styles.is_labels_active]: is_labels_active, })} - href="https://docs.google.com/spreadsheets/d/1_L5i-1U6PB1dnaPPTE_5uKMfqOpkLziPyRkiMLi4mqU/edit?usp=sharing" + href={generateLocalizedDocumentUrl().vrct_document_faq_url} target="_blank" rel="noreferrer" > diff --git a/src-ui/views/assets/about_vrct/localization_4.png b/src-ui/views/assets/about_vrct/localization_4.png index 5410303d..a48fa239 100644 Binary files a/src-ui/views/assets/about_vrct/localization_4.png and b/src-ui/views/assets/about_vrct/localization_4.png differ diff --git a/src-ui/views/assets/about_vrct/localization_5.png b/src-ui/views/assets/about_vrct/localization_5.png index 56e5ac27..a0094d94 100644 Binary files a/src-ui/views/assets/about_vrct/localization_5.png and b/src-ui/views/assets/about_vrct/localization_5.png differ diff --git a/utils/clean.py b/utils/clean.py new file mode 100644 index 00000000..c97ffd0c --- /dev/null +++ b/utils/clean.py @@ -0,0 +1,8 @@ +import os +import shutil + +root = os.path.dirname(os.path.dirname(__file__)) +shutil.rmtree(os.path.join(root, 'build'), ignore_errors=True) +shutil.rmtree(os.path.join(root, 'dist'), ignore_errors=True) +shutil.rmtree(os.path.join(root, 'src-tauri', 'bin'), ignore_errors=True) +shutil.rmtree(os.path.join(root, 'src-tauri', 'target'), ignore_errors=True) \ No newline at end of file diff --git a/task_kill.py b/utils/task_kill.py similarity index 100% rename from task_kill.py rename to utils/task_kill.py diff --git a/utils/update_version.py b/utils/update_version.py new file mode 100644 index 00000000..6aad694f --- /dev/null +++ b/utils/update_version.py @@ -0,0 +1,39 @@ +import os +import json + +def update_versions(): + root = os.path.join(os.path.dirname(os.path.dirname(__file__))) + + # package.jsonからバージョンを読み取る + with open(os.path.join(root, "package.json"), "r", encoding="utf-8") as f: + package_json = json.load(f) + version = package_json["version"] + + # tauri.conf.jsonを更新 + tauri_conf_path = os.path.join(root, "src-tauri", "tauri.conf.json") + with open(tauri_conf_path, "r", encoding="utf-8") as f: + tauri_conf = json.load(f) + + tauri_conf["version"] = version + + with open(tauri_conf_path, "w", encoding="utf-8") as f: + json.dump(tauri_conf, f, indent=4, ensure_ascii=False) + + # config.pyを更新 + config_path = os.path.join(root, "src-python", "config.py") + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + + # VERSION行を置換 + import re + pattern = r'(self\._VERSION = ")[^"]+(")' + replacement = rf'\g<1>{version}\g<2>' + new_content = re.sub(pattern, replacement, content) + + with open(config_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"updated to version {version}") + +if __name__ == "__main__": + update_versions() \ No newline at end of file diff --git a/zip.py b/utils/zip.py similarity index 100% rename from zip.py rename to utils/zip.py