dsh-line-bot 0.9.7 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -31,6 +31,7 @@ curl -s http://127.0.0.1:8791/health
31
31
  | 主動訊息沒出現 | 面板「主動傳訊」 | 每位使用者的倒數還沒到、每日/每月上限、靜音時段、兩則間距冷卻、連續未回覆已被暫停、沒有 session 或不在白名單 | 看 `status.nudge`、使用者表的「下次主動/未回覆」與日誌 |
32
32
  | 餘額提醒沒出現 | `balance` | provider 不是 `deepseek-account`、餘額高於門檻、冷卻中、靜音 | 面板「DeepSeek 餘額」看 `applicable`、`lastAskAt` |
33
33
  | Windows 安裝失敗 | CLI | 找不到 DSH 內建 pnpm;舊版 spawn `.cmd` 失敗 | 用面板安裝,或升級 CLI;必要時 `npm i -g pnpm` |
34
+ | 語音沒送出或一直回文字 | 面板「語音回覆」、日誌 | 尚未按一鍵配置環境、未確認聲音使用權、沒有 ffmpeg、MLX 載入失敗、reply token 過期 | 面板「語音回覆」按一鍵配置與測試;看日誌 `語音回覆`/`語音合成失敗`;必要時看 `status.voice` |
34
35
  | npm `ETARGET` | — | registry/CDN 還沒傳播 | 等 1–5 分鐘再試 |
35
36
 
36
37
  ## 2. 圖片沒送出的排查順序
@@ -50,6 +51,7 @@ curl -s http://127.0.0.1:8791/health
50
51
  - `lib/meme-source.mjs`:讀 dsh-meme。
51
52
  - `lib/chat-import.mjs`:LINE 匯出聊天解析。
52
53
  - `lib/diagnostics.mjs`:doctor 用的檢查工具。
54
+ - `lib/tts.mjs`/`tools/mlx_synth.py`:CosyVoice3 語音合成(MLX/GGUF)。
53
55
  - `bin/cli.mjs`:安裝/`doctor`。
54
56
  - HTTP:`/api/line-bot/status|config|action|doctor`(webServer)、`http://127.0.0.1:<port>/doctor|health|media/<id>|webhook`(外掛自己的伺服器)。
55
57
  - 資料:`$DSH_HOME/line-bot/`(config.json 600、state.json、memories.json、logs、personas、cache、users)。
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 0.10.1
2
+
3
+ - 語音:自然語言語氣(instruct)改用 CosyVoice GGUF;MLX 的 instruct 模式會把語氣指令一起念出來,已避免。
4
+ - 語音:MLX 合成加上 `NUMBA_CACHE_DIR`,避免新版 Python 環境第一次執行就失敗。
5
+ - 面板與文件更新語音引擎說明。
6
+
7
+ ## 0.10.0
8
+
9
+ - 新增語音回覆(CosyVoice3-0.5B):角色可以在回覆最後加 `[[voice]]`,插件會把文字合成語音並用 LINE 語音訊息送出;合成失敗或 reply token 過期時自動改用文字/push。
10
+ - macOS(Apple Silicon)用 MLX 在本機合成;Windows/沒有 MLX 時用 CosyVoice GGUF(CrispASR)。面板可一鍵偵測與配置環境、開關語音、上傳參考音、調整語氣指令與長度、試聽。
11
+ - 參考音預設使用指定音檔的 12–16 秒片段(存在 `<DSH_HOME>/line-bot/tts/ref/default.wav`);可上傳自己的音檔替換。
12
+ - 使用前必須在面板確認「有使用此聲音的權利」;語音檔會存在本機媒體庫,透過 tunnel 的 `/media/<token>` 提供給 LINE。
13
+
1
14
  # Changelog
2
15
 
3
16
  ## 0.9.7
package/README.en.md CHANGED
@@ -99,6 +99,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
99
99
  | Jokes | Text joke library: built-in seeds, Tieba quotes, wanjiqi barrage memes; per-source toggles, per-turn injection count, add/delete entries |
100
100
  | Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
101
101
  | Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
102
+ | Voice replies | CosyVoice3 speech synthesis, on/off, reference upload, instruct style, audio preview |
102
103
  | Sticker & image vision | Fetch incoming LINE stickers or images and show them to the model; when the model cannot read images, describe them with modlens or a vision model (can be disabled) |
103
104
  | DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
104
105
  | Proactive messages | Toggle, per-user countdown, min/max interval, unanswered limit, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics (next possible time and paused users) |
@@ -224,6 +225,15 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
224
225
  - When a LINE sticker arrives, the bot fetches the image and shows it to the model; vision-capable models read it directly, otherwise the bot asks modlens, then tries another vision model, and only then falls back to the keywords LINE provides. Turn "Understand stickers" off in the LINE credentials card to use keywords only.
225
226
  - Regular images work the same way: the bot downloads them through LINE's content API and attaches them to the message; if the model cannot read images, the same modlens / vision-model description path is used. Turn "Understand images" off in the LINE credentials card to skip them.
226
227
  - Without dsh-meme installed, the feature stays off and replies are text only.
228
+ ## Voice replies (CosyVoice3)
229
+
230
+ - The persona can put `[[voice]]` at the end of a reply; the plugin synthesizes the text with CosyVoice3-0.5B and sends a LINE audio message (m4a).
231
+ - On Apple Silicon macOS it synthesizes locally with MLX; on Windows (or without MLX) it uses CosyVoice GGUF (CrispASR). Press "Set up environment" once in the panel (downloads roughly 1–2 GB).
232
+ - The "Voice replies" card toggles the feature, requires confirming that you have the rights to the voice, lets you upload a reference clip (the default is a 12–16 s cut of the provided sample), choose instruct/zero-shot mode, and preview the audio.
233
+ - Natural-language style (instruct) uses CosyVoice GGUF; MLX is only used for zero-shot, so the instruction is never read aloud.
234
+ - Synthesis takes about 20–40 s; if the reply token expires it falls back to push. Audio is stored under `<DSH_HOME>/line-bot/media/` and served at `/media/<token>` through the tunnel.
235
+ - Requires `ffmpeg`; MLX mode needs Python 3.10+.
236
+
227
237
  ## Diagnostics
228
238
  - Panel → Advanced → Run diagnostics checks the server, tunnel, public URL, webhook, dsh-meme library, image converters, model, and recent errors; Safe repair only reconnects the tunnel and re-applies the webhook.
229
239
  - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
@@ -258,4 +268,4 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
258
268
 
259
269
  - Host logic: `lib/*.mjs` (`util` / `config` / `line-api` / `persona` / `agent-runner` / `webhook-server` / `cloudflare` / `tunnel` / `nudge` / `tieba-sync` / `host`).
260
270
  - Panel: `client.js` (registers `settings.section` via `window.__ModuleLoader__`).
261
- - Entry point: `index.js` (mounts `/api/line-bot`, dev reload, cleanup on unload).
271
+ - Entry point: `index.js` (mounts `/api/line-bot`, dev reload, cleanup on unload).
package/README.ja.md CHANGED
@@ -99,6 +99,7 @@ npx dsh-line-bot install --no-companions
99
99
  | ジョーク | テキストネタ集:内蔵シード、貼吧名台詞、wanjiqi 弾幕ネタ。ソース別の切り替え、ターンごとの注入数、追加/削除 |
100
100
  | 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
101
101
  | 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
102
+ | 音声返信 | CosyVoice3 による音声合成、オンオフ、参照音声アップロード、instruct、試聴 |
102
103
  | スタンプ・画像理解 | LINE スタンプや通常の画像を受信したら取得してモデルに見せる。画像非対応モデルは modlens や別の視覚モデルで説明(オフ可) |
103
104
  | DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
104
105
  | 自発メッセージ | オン/オフ、ユーザーごとのカウントダウン、最短/最長間隔、連続未返信の上限、1 日/1 か月の上限、最低間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計(次回予定と一時停止中の人数) |
@@ -224,6 +225,17 @@ Bot がグループ(または複数人のルーム)に追加されたとき
224
225
  - LINE スタンプを受信すると、スタンプ画像を取得してモデルに見せます。画像対応モデルはそのまま読み、非対応ならまず modlens、次に別の視覚モデルで説明し、最後に LINE のキーワードを使います。「LINE 認証情報」カードの「スタンプを理解する」でオフにできます。
225
226
  - 通常の画像も同じです。LINE content API でダウンロードしてモデルに添付し、画像非対応なら同じ modlens/視覚モデルの説明経路を使います。「LINE 認証情報」カードの「画像を理解する」でオフにできます。
226
227
  - dsh-meme がインストールされていない場合、この機能は無効になりテキストのみ返します。
228
+ ## 音声返信(CosyVoice3)
229
+
230
+ - ペルソナは返信の最後に `[[voice]]` を付けると、CosyVoice3-0.5B で音声合成し、LINE の音声メッセージ(m4a)として送信します。
231
+ - Apple Silicon の macOS では MLX でローカル合成します。Windows や MLX がない環境では CosyVoice GGUF(CrispASR)を使います。初回はパネルの「環境を一括セットアップ」を押してください(モデルを約 1–2GB ダウンロードします)。
232
+ - パネルの「音声返信」でオンオフ、音声使用権の確認、参照音声のアップロード(既定は指定音源の 12–16 秒)、instruct/zero-shot の選択、試聴ができます。
233
+ - 合成には約 20–40 秒かかります。reply token の期限が切れた場合は push に切り替わります。音声は `<DSH_HOME>/line-bot/media/` に保存され、tunnel の `/media/<token>` で配信されます。
234
+ - `ffmpeg` が必要です。MLX モードは Python 3.10+ が必要です。
235
+
236
+ - 自然言語の話し方(instruct)は CosyVoice GGUF を使用します。MLX はゼロショット専用で、指示が読み上げられるのを防ぎます。
237
+
238
+
227
239
  ## 診断
228
240
  - パネルの「詳細設定 → 診断を実行」でサーバー、Tunnel、公開 URL、webhook、dsh-meme 図庫、画像変換ツール、モデル、最近のエラーを確認できます。「安全な修復」は tunnel 再接続と webhook 再設定だけを行います。
229
241
  - CLI:`npx dsh-line-bot doctor`、`doctor --fix`、`doctor --json`。
@@ -258,4 +270,4 @@ Bot がグループ(または複数人のルーム)に追加されたとき
258
270
 
259
271
  - host ロジック:`lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`)。
260
272
  - パネル:`client.js`(`window.__ModuleLoader__` で `settings.section` を登録)。
261
- - エントリーポイント:`index.js`(`/api/line-bot` のマウント、dev reload、アンロード時のクリーンアップ)。
273
+ - エントリーポイント:`index.js`(`/api/line-bot` のマウント、dev reload、アンロード時のクリーンアップ)。
package/README.ko.md CHANGED
@@ -99,6 +99,7 @@ npx dsh-line-bot install --no-companions
99
99
  | 말장난 | 텍스트 밈: 내장 시드, 貼吧 명언, wanjiqi 탄막 밈. 출처별 켜기/끄기, 턴당 주입 수, 추가/삭제 |
100
100
  | 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
101
101
  | 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
102
+ | 음성 답장 | CosyVoice3 음성 합성, 켜기/끄기, 참조 음성 업로드, instruct, 미리듣기 |
102
103
  | 스티커·이미지 이해 | LINE 스티커나 일반 이미지를 받으면 가져와 모델에 보여 줌. 이미지 미지원 모델은 modlens 또는 다른 비전 모델로 설명 (끌 수 있음) |
103
104
  | DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
104
105
  | 능동 메시지 | 켜기/끄기, 사용자별 카운트다운, 최소/최대 간격, 연속 미응답 상한, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계(다음 예정 시각과 일시 중지 인원) |
@@ -222,6 +223,17 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
222
223
  - LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다. 이미지 지원 모델은 바로 읽고, 아니면 먼저 modlens, 다음으로 다른 비전 모델로 설명하고, 마지막에 LINE 키워드로 대체합니다. 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
223
224
  - 일반 이미지도 같습니다. LINE content API로 내려받아 모델에 첨부하고, 이미지 미지원이면 같은 modlens/비전 모델 설명 경로를 씁니다. 「LINE 자격 증명」 카드에서 「이미지 이해」를 끌 수 있습니다.
224
225
  - dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
226
+ ## 음성 답장(CosyVoice3)
227
+
228
+ - 페르소나가 답장 끝에 `[[voice]]`를 붙이면 CosyVoice3-0.5B로 음성을 합성해 LINE 음성 메시지(m4a)로 보냅니다.
229
+ - Apple Silicon macOS에서는 MLX로 로컬 합성합니다. Windows이거나 MLX가 없으면 CosyVoice GGUF(CrispASR)를 사용합니다. 처음에는 패널의 「환경 일괄 설정」을 한 번 누르세요(모델 약 1–2GB 다운로드).
230
+ - 패널 「음성 답장」에서 켜기/끄기, 음성 사용 권리 확인, 참조 음성 업로드(기본값은 지정 음원의 12–16초), instruct/zero-shot 선택, 미리듣기를 할 수 있습니다.
231
+ - 합성에는 약 20–40초가 걸립니다. reply token이 만료되면 push로 자동 전환됩니다. 음성은 `<DSH_HOME>/line-bot/media/`에 저장되고 tunnel의 `/media/<token>`으로 제공됩니다.
232
+ - `ffmpeg`가 필요합니다. MLX 모드는 Python 3.10+가 필요합니다.
233
+
234
+ - 자연어 말투(instruct)는 CosyVoice GGUF를 사용합니다. MLX는 제로샷 전용이라 지시문이 읽히지 않습니다.
235
+
236
+
225
237
  ## 진단
226
238
  - 패널의 "고급 설정 → 진단 실행"에서 서버, Tunnel, 공개 URL, webhook, dsh-meme 라이브러리, 이미지 변환 도구, 모델, 최근 오류를 확인할 수 있습니다. "안전 복구"는 tunnel 재연결과 webhook 재설정만 수행합니다.
227
239
  - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
@@ -256,4 +268,4 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
256
268
 
257
269
  - host 로직: `lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`).
258
270
  - 패널: `client.js`(`window.__ModuleLoader__`로 `settings.section` 등록).
259
- - 진입점: `index.js`(`/api/line-bot` 마운트, dev reload, 언로드 정리).
271
+ - 진입점: `index.js`(`/api/line-bot` 마운트, dev reload, 언로드 정리).
package/README.md CHANGED
@@ -99,6 +99,7 @@ npx dsh-line-bot install --no-companions
99
99
  | 玩梗 | 文字梗庫:內建種子、貼吧金句、wanjiqi 彈幕梗;三個來源可個別開關、每輪注入幾則、新增/刪除梗 |
100
100
  | 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
101
101
  | 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
102
+ | 語音回覆 | CosyVoice3 語音合成、開關、參考音上傳、語氣指令、試聽 |
102
103
  | 貼圖與圖片理解 | 收到 LINE 貼圖或一般圖片時抓圖給模型看;模型不吃圖時用 modlens 或另一個視覺模型描述(可關閉) |
103
104
  | DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
104
105
  | 主動傳訊 | 開關、每位使用者的倒數、最短/最長間隔、連續未回覆上限、每日/每月上限、兩則間距、安靜時段、idle、指定對象、提示詞、立即試傳、統計(下次可能時間與暫停人數) |
@@ -224,6 +225,15 @@ Bot 被加進群組(或多人聊天室)後:
224
225
  - 收到 LINE 貼圖時,會抓貼圖圖片交給模型看;模型支援圖片就直接讀,否則先用 modlens 描述,再不支援就改用另一個視覺模型,最後才退回 LINE 提供的關鍵字。可在「LINE 憑證」卡關閉「看得懂貼圖」。
225
226
  - 一般圖片也一樣:透過 LINE content API 下載後直接附給模型;模型不吃圖時走同一套 modlens/視覺模型描述。可在「LINE 憑證」卡關閉「看得懂一般圖片」。
226
227
  - 沒安裝 dsh-meme 時這個功能會自動停用,只回文字。
228
+ ## 語音回覆(CosyVoice3)
229
+
230
+ - 角色可以在回覆最後加 `[[voice]]`,插件會用 CosyVoice3-0.5B 把文字合成語音,並以 LINE 語音訊息送出(m4a)。
231
+ - macOS(Apple Silicon)在本機用 MLX 合成;Windows/沒有 MLX 時用 CosyVoice GGUF(CrispASR)。第一次要先在面板按「一鍵配置環境」(會下載模型,約 1–2GB)。
232
+ - 面板「語音回覆」可開關、確認聲音使用權、上傳參考音(預設用指定音檔的 12–16 秒片段)、選推理模式與語氣指令、試聽。
233
+ - 自然語言語氣(instruct)使用 CosyVoice GGUF;MLX 只用於零樣本模式,避免語氣指令被念出來。
234
+ - 合成需要約 20–40 秒;若 reply token 過期會自動改用 push。語音檔存在 `<DSH_HOME>/line-bot/media/`,透過 tunnel 的 `/media/<token>` 提供。
235
+ - 需要 `ffmpeg`;MLX 模式需要 Python 3.10+。
236
+
227
237
  ## 診斷與修復
228
238
  - 面板「進階設定 → 執行診斷」會檢查伺服器、Tunnel、公開網址、webhook、表情包圖庫、轉圖工具、模型與最近錯誤;「自動修復(安全)」只會重連 tunnel、重設 webhook。
229
239
  - 終端機:`npx dsh-line-bot doctor`、`npx dsh-line-bot doctor --fix`、`doctor --json`。
@@ -258,4 +268,4 @@ Bot 被加進群組(或多人聊天室)後:
258
268
 
259
269
  - host 邏輯:`lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`)。
260
270
  - 面板:`client.js`(`window.__ModuleLoader__` 註冊 `settings.section`)。
261
- - 進入點:`index.js`(掛 `/api/line-bot`、dev reload、卸載清理)。
271
+ - 進入點:`index.js`(掛 `/api/line-bot`、dev reload、卸載清理)。
package/README.zh-CN.md CHANGED
@@ -99,6 +99,7 @@ npx dsh-line-bot install --no-companions
99
99
  | 玩梗 | 文字梗库:内置种子、贴吧金句、wanjiqi 弹幕梗;三个来源可分别开关、每轮注入几条、新增/删除梗 |
100
100
  | 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
101
101
  | 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
102
+ | 语音回复 | CosyVoice3 语音合成、开关、参考音频上传、语气指令、试听 |
102
103
  | 贴图与图片理解 | 收到 LINE 贴图或一般图片时抓图给模型看;模型不吃图时用 modlens 或另一个视觉模型描述(可关闭) |
103
104
  | DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
104
105
  | 主动传讯 | 开关、每位使用者的倒数、最短/最长间隔、连续未回复上限、每日/每月上限、两则间距、安静时段、idle、指定对象、提示词、立即试传、统计(下次可能时间与暂停人数) |
@@ -224,6 +225,15 @@ Bot 被加入群组(或多人聊天室)后:
224
225
  - 收到 LINE 贴图时,会抓贴图图片交给模型看;模型支持图片就直接读,否则先用 modlens 描述,再不支持就改用另一个视觉模型,最后才退回 LINE 提供的关键词。可在「LINE 凭证」卡关闭「看得懂贴图」。
225
226
  - 一般图片也一样:通过 LINE content API 下载后直接附给模型;模型不吃图时走同一套 modlens/视觉模型描述。可在「LINE 凭证」卡关闭「看得懂一般图片」。
226
227
  - 没安装 dsh-meme 时该功能会自动停用,只回文字。
228
+ ## 语音回复(CosyVoice3)
229
+
230
+ - 角色可以在回复最后加 `[[voice]]`,插件会用 CosyVoice3-0.5B 把文字合成语音,并以 LINE 语音消息发送(m4a)。
231
+ - macOS(Apple Silicon)在本机用 MLX 合成;Windows/没有 MLX 时用 CosyVoice GGUF(CrispASR)。第一次要先在面板按「一键配置环境」(会下载模型,约 1–2GB)。
232
+ - 面板「语音回复」可开关、确认声音使用权、上传参考音频(默认用指定音频的 12–16 秒片段)、选择推理模式与语气指令、试听。
233
+ - 自然语言语气(instruct)使用 CosyVoice GGUF;MLX 只用于零样本模式,避免语气指令被念出来。
234
+ - 合成需要约 20–40 秒;若 reply token 过期会自动改用 push。语音文件存在 `<DSH_HOME>/line-bot/media/`,通过 tunnel 的 `/media/<token>` 提供。
235
+ - 需要 `ffmpeg`;MLX 模式需要 Python 3.10+。
236
+
227
237
  ## 诊断与修复
228
238
  - 面板「高级设置 → 执行诊断」会检查服务器、Tunnel、公开网址、webhook、表情包图库、转图工具、模型与最近错误;「自动修复(安全)」只会重连 tunnel、重设 webhook。
229
239
  - 终端:`npx dsh-line-bot doctor`、`npx dsh-line-bot doctor --fix`、`doctor --json`。
@@ -258,4 +268,4 @@ Bot 被加入群组(或多人聊天室)后:
258
268
 
259
269
  - host 逻辑:`lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`)。
260
270
  - 面板:`client.js`(`window.__ModuleLoader__` 注册 `settings.section`)。
261
- - 进入点:`index.js`(挂 `/api/line-bot`、dev reload、卸载清理)。
271
+ - 进入点:`index.js`(挂 `/api/line-bot`、dev reload、卸载清理)。
@@ -32,6 +32,15 @@ npx dsh-line-bot doctor --fix # 安全修復:重連 tunnel、重設 webhook
32
32
  - 模型不支援時,插件會先用 modlens 描述,再改用另一個視覺模型,最後才退回貼圖關鍵字。
33
33
  - modlens 可在「進階 → 診斷」看 `vision` check;失敗通常是 provider 額度或 503。一般圖片可在面板「LINE 憑證」關閉「看得懂一般圖片」。
34
34
 
35
+ ### 語音沒送出或一直回文字
36
+ - 面板「語音回覆」要開啟、確認聲音使用權,並按過「一鍵配置環境」(第一次會下載模型)。
37
+ - macOS 需要 Apple Silicon + Python 3.10+;Windows 需要 `ffmpeg`。音檔上傳後可按「測試語音」試聽。
38
+ - 合成約需 20–40 秒;reply token 過期時會自動改用 push。日誌看 `語音回覆`、`語音合成失敗`。
39
+ - 模型只有在語言上下文有語音提示時才會加 `[[voice]]`;可先用「測試 Agent 回覆」確認。
40
+
41
+ ### 語音把語氣指令念出來
42
+ - 舊版 MLX 的 instruct 模式會把語氣描述一起念;0.10.1 起 instruct 一律走 CosyVoice GGUF。升級後重新產生一次語音即可,舊音檔不會變。
43
+
35
44
  ### APNG 不會動
36
45
  - LINE 不播放 APNG,只顯示第一幀。保持 `stickers.apng=false` 即可。
37
46
 
package/client.js CHANGED
@@ -23,6 +23,7 @@ window.__ModuleLoader__.load({
23
23
  { key: 'balance', label: 'DeepSeek 餘額' },
24
24
  { key: 'nudge', label: '主動傳訊' },
25
25
  { key: 'meme', label: '表情包' },
26
+ { key: 'voice', label: '語音回覆' },
26
27
  { key: 'users', label: '使用者' },
27
28
  { key: 'group', label: '群組' },
28
29
  { key: 'advanced', label: '進階/日誌' },
@@ -366,6 +367,8 @@ window.__ModuleLoader__.load({
366
367
  const [modelCatalog, setModelCatalog] = useState(null)
367
368
  const [importGroup, setImportGroup] = useState('')
368
369
  const [doctorResult, setDoctorResult] = useState(null)
370
+ const [voicePreview, setVoicePreview] = useState(null)
371
+ const voiceInputRef = useRef(null)
369
372
  const importInput = useRef(null)
370
373
  const [localEdit, setLocalEdit] = useState({ id: '', name: '', text: '' })
371
374
 
@@ -697,6 +700,91 @@ window.__ModuleLoader__.load({
697
700
  await act('tieba-bars-add', {})
698
701
  }
699
702
 
703
+ async function installVoice() {
704
+ setBusy('voice-install')
705
+ setToast(null)
706
+ try {
707
+ await jpost('/action', { action: 'voice-install' })
708
+ setToast({ kind: 'ok', text: '已開始配置語音環境;完成前可以留在這一頁看狀態。' })
709
+ } catch (err) {
710
+ setToast({ kind: 'error', text: '配置失敗:' + (err.message || err) })
711
+ } finally {
712
+ setBusy('')
713
+ }
714
+ }
715
+
716
+ async function detectVoice() {
717
+ setBusy('voice-detect')
718
+ setToast(null)
719
+ try {
720
+ const value = await jpost('/action', { action: 'voice-detect' })
721
+ const result = value?.result || {}
722
+ setToast({ kind: 'ok', text: '偵測完成:' + (result.ready ? '環境就緒' : '尚未就緒(可按一鍵配置)') })
723
+ await refresh()
724
+ } catch (err) {
725
+ setToast({ kind: 'error', text: '偵測失敗:' + (err.message || err) })
726
+ } finally {
727
+ setBusy('')
728
+ }
729
+ }
730
+
731
+ async function testVoice() {
732
+ if (dirty) await persist(false).catch(() => {})
733
+ setBusy('voice-preview')
734
+ setToast(null)
735
+ try {
736
+ const value = await jpost('/action', { action: 'voice-preview', payload: { text: '主人,這是語音測試,聽得到嗎?' } })
737
+ const result = value?.result || null
738
+ setVoicePreview(result)
739
+ setToast({ kind: 'ok', text: result?.url ? '合成完成,可以試聽。' : '合成完成,但沒有公開網址;請確認 tunnel。' })
740
+ } catch (err) {
741
+ setToast({ kind: 'error', text: '語音測試失敗:' + (err.message || err) })
742
+ } finally {
743
+ setBusy('')
744
+ }
745
+ }
746
+
747
+ async function uploadVoice(event) {
748
+ const file = event?.target?.files?.[0]
749
+ if (event?.target) event.target.value = ''
750
+ if (!file) return
751
+ if (file.size > 20 * 1024 * 1024) {
752
+ setToast({ kind: 'error', text: '音檔超過 20MB。' })
753
+ return
754
+ }
755
+ setBusy('voice-save-reference')
756
+ setToast(null)
757
+ try {
758
+ const dataUrl = await new Promise((resolve, reject) => {
759
+ const reader = new FileReader()
760
+ reader.onload = () => resolve(String(reader.result || ''))
761
+ reader.onerror = () => reject(new Error('讀取檔案失敗'))
762
+ reader.readAsDataURL(file)
763
+ })
764
+ await jpost('/action', { action: 'voice-save-reference', payload: { dataUrl, filename: file.name } })
765
+ await refresh()
766
+ setToast({ kind: 'ok', text: '參考音已更新。' })
767
+ } catch (err) {
768
+ setToast({ kind: 'error', text: '上傳失敗:' + (err.message || err) })
769
+ } finally {
770
+ setBusy('')
771
+ }
772
+ }
773
+
774
+ async function resetVoiceReference() {
775
+ setBusy('voice-reset-reference')
776
+ setToast(null)
777
+ try {
778
+ await jpost('/action', { action: 'voice-reset-reference' })
779
+ await refresh()
780
+ setToast({ kind: 'ok', text: '已改用內建預設參考音。' })
781
+ } catch (err) {
782
+ setToast({ kind: 'error', text: '重設失敗:' + (err.message || err) })
783
+ } finally {
784
+ setBusy('')
785
+ }
786
+ }
787
+
700
788
  async function refresh() {
701
789
  setToast(null)
702
790
  try {
@@ -757,6 +845,9 @@ window.__ModuleLoader__.load({
757
845
  const memoryStatus = status.memory || {}
758
846
  const agentStatus = status.agent || {}
759
847
  const modelProviders = Array.isArray(modelCatalog?.providers) ? modelCatalog.providers : []
848
+ const voiceCfg = cfg.voice || {}
849
+ const voiceStatus = status.voice || {}
850
+ const voiceSetup = voiceStatus.setup || {}
760
851
  const memoryUsers = memoryData?.users || []
761
852
  const syncStats = status.sync || {}
762
853
  const runtime = status
@@ -1317,10 +1408,48 @@ window.__ModuleLoader__.load({
1317
1408
  ),
1318
1409
  ),
1319
1410
 
1411
+ // 5.5 語音回覆
1412
+ h(
1413
+ Card,
1414
+ { id: sectionDomId('voice'), order: 12, title: '語音回覆(CosyVoice3)', desc: '用 CosyVoice3-0.5B 把回覆合成語音;角色可以自己決定要不要用語音,也可以只用文字。第一次使用要先按「一鍵配置環境」。' },
1415
+ h('div', { style: S.row },
1416
+ h(Badge, { ok: voiceStatus.ready === true }, voiceStatus.ready ? '語音環境就緒' : '尚未就緒'),
1417
+ h(Badge, { ok: voiceCfg.consent === true }, voiceCfg.consent ? '已確認使用權' : '未確認使用權'),
1418
+ h(Badge, { ok: true }, voiceStatus.effectiveProvider === 'gguf' ? 'GGUF' : voiceStatus.effectiveProvider === 'mlx' ? 'MLX' : '未安裝引擎'),
1419
+ h(Badge, { ok: !!voiceStatus.reference?.path }, voiceStatus.reference?.path ? ('參考音 ' + (voiceStatus.reference.seconds || 0) + ' 秒') : '沒有參考音'),
1420
+ ),
1421
+ h('div', { style: S.hint }, '自然語言語氣(instruct)一律用 CosyVoice GGUF,避免 MLX 把語氣指令念出來;零樣本模式可用 MLX。合成一次約需 20–40 秒,reply token 過期會自動改用 push。'),
1422
+ h(Toggle, { checked: voiceCfg.enabled === true, onChange: (value) => update({ voice: { enabled: value } }), label: '開啟語音回覆', hint: '開啟後角色可以自己決定用語音;關閉時完全不合成。' }),
1423
+ h(Toggle, { checked: voiceCfg.consent === true, onChange: (value) => update({ voice: { consent: value } }), label: '我確認有使用此聲音的權利', hint: '使用他人聲音做克隆前請先取得同意;確認後才會合成。' }),
1424
+ h('div', { style: S.row },
1425
+ h(Btn, { onClick: installVoice, disabled: !!busy }, busy === 'voice-install' ? '安裝中…' : '一鍵配置環境'),
1426
+ h(Btn, { onClick: detectVoice, disabled: !!busy }, busy === 'voice-detect' ? '偵測中…' : '重新偵測'),
1427
+ h(Btn, { onClick: testVoice, disabled: !!busy }, busy === 'voice-preview' ? '合成中…' : '測試語音'),
1428
+ h(Btn, { onClick: () => voiceInputRef.current && voiceInputRef.current.click(), disabled: !!busy }, '上傳參考音'),
1429
+ h('input', { ref: voiceInputRef, type: 'file', accept: 'audio/*', style: { display: 'none' }, onChange: uploadVoice }),
1430
+ ),
1431
+ h('div', { style: S.hint }, '目前參考音:' + (voiceStatus.reference?.path || '(尚未設定,預設會用插件內建的 12–16 秒片段)')),
1432
+ voiceStatus.reference?.path ? h('div', { style: S.row }, h(Btn, { onClick: resetVoiceReference, disabled: !!busy }, '改用內建預設參考音')) : null,
1433
+ h('div', { style: S.grid },
1434
+ h(Field, { label: '引擎', hint: 'auto 會依系統自動挑選' }, h('select', { value: voiceCfg.provider || 'auto', onChange: (event) => update({ voice: { provider: event.target.value } }), style: S.input }, ['auto', 'mlx', 'gguf'].map((item) => h('option', { key: item, value: item }, item)))),
1435
+ h(Field, { label: '推理模式' }, h('select', { value: voiceCfg.cloneMode || 'instruct', onChange: (event) => update({ voice: { cloneMode: event.target.value } }), style: S.input }, [['instruct', '自然語言語氣控制'], ['zero-shot', '零樣本(需參考音文字)']].map(([value, label]) => h('option', { key: value, value }, label)))),
1436
+ h(Field, { label: '一次最多幾個字', hint: '太長會截斷;語音訊息上限約 60 秒' }, h(Num, { value: voiceCfg.maxChars, min: 20, max: 300, step: 10, onChange: (value) => update({ voice: { maxChars: value } }) })),
1437
+ ),
1438
+ h(Field, { label: '語氣指令(instruct)', hint: '用自然語言描述想要的語氣' }, h(Area, { value: voiceCfg.instructText, rows: 2, onChange: (value) => update({ voice: { instructText: value } }) })),
1439
+ voiceCfg.cloneMode === 'zero-shot' ? h(Field, { label: '參考音文字(zero-shot 用)' }, h(Area, { value: voiceCfg.refText, rows: 2, onChange: (value) => update({ voice: { refText: value } }) })) : null,
1440
+ voiceSetup.phase && voiceSetup.phase !== 'idle'
1441
+ ? h('div', { style: voiceSetup.phase === 'error' ? S.warnBox : S.note }, '環境狀態:' + (voiceSetup.message || voiceSetup.phase) + (voiceSetup.error ? '(' + voiceSetup.error + ')' : ''))
1442
+ : null,
1443
+ voiceStatus.lastError ? h('div', { style: S.warnBox }, '最近錯誤:' + voiceStatus.lastError) : null,
1444
+ voicePreview
1445
+ ? h('div', { style: S.panelBox }, h('div', { style: S.fieldTitle }, '試聽(' + (voicePreview.provider || '') + ',' + Math.round((voicePreview.durationMs || 0) / 1000) + ' 秒)'), h('audio', { controls: true, src: voicePreview.url || voicePreview.localUrl || '', style: { width: '100%' } }))
1446
+ : null,
1447
+ ),
1448
+
1320
1449
  // 6. 使用者
1321
1450
  h(
1322
1451
  Card,
1323
- { id: sectionDomId('users'), order: 12, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1452
+ { id: sectionDomId('users'), order: 13, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1324
1453
  (access.pending || []).length > 0
1325
1454
  ? h('div', { style: S.panelBox },
1326
1455
  h('div', { style: S.fieldTitle }, `待審核(${access.pending.length})`),
@@ -1370,7 +1499,7 @@ window.__ModuleLoader__.load({
1370
1499
  // 6.5 群組
1371
1500
  h(
1372
1501
  Card,
1373
- { id: sectionDomId('group'), order: 13, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session、工作資料夾、人設與記憶;比較晚加入時可以匯入 LINE 匯出的聊天記錄補記憶。' },
1502
+ { id: sectionDomId('group'), order: 14, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session、工作資料夾、人設與記憶;比較晚加入時可以匯入 LINE 匯出的聊天記錄補記憶。' },
1374
1503
  h('input', { ref: importInput, type: 'file', accept: '.txt,text/plain', style: { display: 'none' }, onChange: importChatFile }),
1375
1504
  h('div', { style: S.grid },
1376
1505
  h(Field, { label: '沒被點名時的回覆機率(%)', hint: '每則群組訊息用這個機率決定要不要回;0 就只在被 @ 或引用時回' },
package/lib/config.mjs CHANGED
@@ -35,6 +35,19 @@ export const DEFAULT_CONFIG = {
35
35
  errorReply: '(剛剛卡住了,再說一次好嗎?)',
36
36
  emptyReply: '(她沒有說話)',
37
37
  },
38
+ voice: {
39
+ enabled: false,
40
+ consent: false,
41
+ provider: 'auto', // auto | mlx | gguf
42
+ cloneMode: 'instruct', // instruct | zero-shot
43
+ instructText: '用可愛、輕快、帶點撒嬌的語氣說,語速稍慢。',
44
+ refText: '',
45
+ maxChars: 80,
46
+ model: '',
47
+ referencePath: '',
48
+ referenceSource: '',
49
+ skipDisclaimer: true,
50
+ },
38
51
  server: {
39
52
  host: '127.0.0.1',
40
53
  port: 8791,
@@ -245,6 +258,19 @@ export function sanitizePatch(current, patch) {
245
258
  next.agent.workspaceRoot = String(next.agent.workspaceRoot || '').trim()
246
259
  next.agent.tiebaContext = Boolean(next.agent.tiebaContext)
247
260
 
261
+ const voice = next.voice || (next.voice = {})
262
+ voice.enabled = Boolean(voice.enabled)
263
+ voice.consent = Boolean(voice.consent)
264
+ voice.provider = ['auto', 'mlx', 'gguf'].includes(String(voice.provider)) ? String(voice.provider) : 'auto'
265
+ voice.cloneMode = String(voice.cloneMode) === 'zero-shot' ? 'zero-shot' : 'instruct'
266
+ voice.instructText = String(voice.instructText || '').slice(0, 500)
267
+ voice.refText = String(voice.refText || '').slice(0, 500)
268
+ voice.maxChars = clampInt(voice.maxChars, 20, 300, 80)
269
+ voice.model = String(voice.model || '').slice(0, 200)
270
+ voice.referencePath = String(voice.referencePath || '').trim()
271
+ voice.referenceSource = String(voice.referenceSource || '').trim()
272
+ voice.skipDisclaimer = Boolean(voice.skipDisclaimer)
273
+
248
274
  const sync = next.sync
249
275
  sync.enabled = Boolean(sync.enabled)
250
276
  sync.targetPersonaId = String(sync.targetPersonaId || '').trim() || 'dafeiyu'