dsh-line-bot 0.9.6 → 0.10.0

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,5 +1,19 @@
1
+ ## 0.10.0
2
+
3
+ - 新增語音回覆(CosyVoice3-0.5B):角色可以在回覆最後加 `[[voice]]`,插件會把文字合成語音並用 LINE 語音訊息送出;合成失敗或 reply token 過期時自動改用文字/push。
4
+ - macOS(Apple Silicon)用 MLX 在本機合成;Windows/沒有 MLX 時用 CosyVoice GGUF(CrispASR)。面板可一鍵偵測與配置環境、開關語音、上傳參考音、調整語氣指令與長度、試聽。
5
+ - 參考音預設使用指定音檔的 12–16 秒片段(存在 `<DSH_HOME>/line-bot/tts/ref/default.wav`);可上傳自己的音檔替換。
6
+ - 使用前必須在面板確認「有使用此聲音的權利」;語音檔會存在本機媒體庫,透過 tunnel 的 `/media/<token>` 提供給 LINE。
7
+
1
8
  # Changelog
2
9
 
10
+ ## 0.9.7
11
+
12
+ - 沒有大肥魚外掛(`dsh-persona-dafeiyu`)時,自動改用插件內建的繁體中文人設:大肥魚、阿茶、聽瀾;人設選擇與貼吧同步目標會自動在 `dafeiyu` 與 `local:builtin-*` 之間切換。
13
+ - 內建繁中版本存在 `<DSH_HOME>/line-bot/personas.json`,可在面板「回覆人設」的人設庫直接編輯;刪除後重新載入插件才會補回。
14
+ - 有安裝大肥魚外掛時,下拉選單仍以大肥魚的人設為準,避免兩套重複;面板人設庫仍可編輯內建繁中備用版本。
15
+ - 修正讀不到大肥魚外掛時,清單仍顯示無法套用的人設。
16
+
3
17
  ## 0.9.6
4
18
 
5
19
  - 回覆可分段送出:依空行(段落)或每個換行拆成多個 LINE 訊息框,最多 5 則;超過 5 段會把後面的段落併進最後一則。
package/README.en.md CHANGED
@@ -11,7 +11,7 @@ Built in:
11
11
  - **Webhook server**: opens a local HTTP server on `127.0.0.1`, handling LINE's `X-Line-Signature` verification, event deduplication, and a separate queue per user.
12
12
  - **Cloudflare Tunnel**: one click opens a quick tunnel (`*.trycloudflare.com`, no Cloudflare account needed), or paste a Cloudflare API token to create a named tunnel + DNS for a fixed URL; once the URL is ready it writes the webhook endpoint back to LINE and runs `webhook/test`.
13
13
  - **LINE credentials**: fill in Channel ID + Channel Secret and it issues an access token automatically (`/v2/oauth/accessToken`, about 30 days, auto-renewed near expiry); you can also paste an existing Channel Access Token.
14
- - **Personas**: pick any built-in or custom persona from the dafeiyu plugin in the panel, or use the plugin's own custom personas (stored in `<DSH_HOME>/line-bot/personas.json`, add/edit/import). Persona selection is per-session and does not affect other conversations in the GUI.
14
+ - **Without the dafeiyu plugin**: the plugin falls back to its own Traditional-Chinese editions of 大肥魚 / 阿茶 / 聽瀾, stored in the same `personas.json` and editable like custom personas. Selecting dafeiyu again switches back automatically.
15
15
  - **Proactive messages**: a scheduler built into the plugin (no launchd). Each user gets an independent randomized countdown (between a minimum and maximum interval); it only speaks when the countdown is due and the user has been idle long enough. Configurable min/max interval, daily/monthly caps, minimum gap, quiet hours, idle time, unanswered limit (pause after that many unanswered proactive messages, reset when the user replies), target users and prompt. The Agent can reply `SKIP` when it is not a good moment.
16
16
  - **Split replies**: optionally split a reply into up to five LINE bubbles by blank line (paragraph) or every newline, instead of one long bubble; the panel can change the mode or turn it off.
17
17
  - **Full settings panel**: every setting lives under Settings → LINE Bot and is stored in `<DSH_HOME>/line-bot/config.json` (mode 600).
@@ -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,14 @@ 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
+ - 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.
234
+ - Requires `ffmpeg`; MLX mode needs Python 3.10+.
235
+
227
236
  ## Diagnostics
228
237
  - 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
238
  - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
@@ -258,4 +267,4 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
258
267
 
259
268
  - Host logic: `lib/*.mjs` (`util` / `config` / `line-api` / `persona` / `agent-runner` / `webhook-server` / `cloudflare` / `tunnel` / `nudge` / `tieba-sync` / `host`).
260
269
  - Panel: `client.js` (registers `settings.section` via `window.__ModuleLoader__`).
261
- - Entry point: `index.js` (mounts `/api/line-bot`, dev reload, cleanup on unload).
270
+ - Entry point: `index.js` (mounts `/api/line-bot`, dev reload, cleanup on unload).
package/README.ja.md CHANGED
@@ -11,7 +11,7 @@ LINE 公式アカウント(Messaging API)を DeepSeek Harness につなぐ
11
11
  - **Webhook サーバー**:ローカルの `127.0.0.1` に HTTP サーバーを開き、LINE の `X-Line-Signature` 検証、イベントの重複排除、ユーザーごとのキューを処理します。
12
12
  - **Cloudflare Tunnel**:ワンクリックの quick tunnel(`*.trycloudflare.com`、Cloudflare アカウント不要)、または Cloudflare API トークンを貼り付けて名前付きトンネル+DNS を作成し固定 URL を取得します。URL が取得できたら webhook エンドポイントを LINE に書き戻し、`webhook/test` を実行します。
13
13
  - **LINE 認証情報**:Channel ID と Channel Secret を入力すれば access token を自動発行(`/v2/oauth/accessToken`、約 30 日、期限が近づくと自動更新)。既存の Channel Access Token を貼り付けることもできます。
14
- - **ペルソナ**:パネルから大肥魚プラグインの内蔵/カスタムペルソナを選べるほか、プラグイン内蔵のカスタムペルソナ(`<DSH_HOME>/line-bot/personas.json`、追加・編集・インポート可)も使えます。ペルソナはセッション単位で指定され、GUI の他の会話には影響しません。
14
+ - **大肥魚プラグインがない場合**:プラグイン内蔵の繁体字中国語版(大肥魚/阿茶/聽瀾)に自動で切り替わります。同じ `personas.json` に保存され、カスタムペルソナと同じように編集できます。大肥魚を入れ直すと自動でそちらに戻ります。
15
15
  - **自発メッセージ**:プラグイン内蔵のスケジューラー(launchd 不要)。ユーザーごとに独立したランダムなカウントダウン(最短~最長間隔)を持ち、時間が来て相手の沈黙が十分長いときだけ話しかけます。最短/最長間隔、1 日/1 か月の上限、最低間隔、静かな時間帯、idle、連続未返信の上限(上限に達したら一時停止、相手が返信したらリセット)、対象ユーザー、プロンプトを設定できます。Agent が「今は違う」と判断したら `SKIP` を返して見送れます。
16
16
  - **分割返信**:返信を空行(段落)または改行ごとに最大 5 つの LINE メッセージに分割できます。1 つの長い吹き出しに詰め込まずに送れます。パネルで方式の切り替えやオフも可能です。
17
17
  - **充実した設定パネル**:設定はすべて「設定 → LINE Bot」にあり、`<DSH_HOME>/line-bot/config.json`(モード 600)に保存されます。
@@ -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,14 @@ 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
+
227
236
  ## 診断
228
237
  - パネルの「詳細設定 → 診断を実行」でサーバー、Tunnel、公開 URL、webhook、dsh-meme 図庫、画像変換ツール、モデル、最近のエラーを確認できます。「安全な修復」は tunnel 再接続と webhook 再設定だけを行います。
229
238
  - CLI:`npx dsh-line-bot doctor`、`doctor --fix`、`doctor --json`。
@@ -258,4 +267,4 @@ Bot がグループ(または複数人のルーム)に追加されたとき
258
267
 
259
268
  - host ロジック:`lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`)。
260
269
  - パネル:`client.js`(`window.__ModuleLoader__` で `settings.section` を登録)。
261
- - エントリーポイント:`index.js`(`/api/line-bot` のマウント、dev reload、アンロード時のクリーンアップ)。
270
+ - エントリーポイント:`index.js`(`/api/line-bot` のマウント、dev reload、アンロード時のクリーンアップ)。
package/README.ko.md CHANGED
@@ -11,7 +11,7 @@ LINE 공식 계정(Messaging API)을 DeepSeek Harness에 연결하는 플러그
11
11
  - **Webhook 서버**: 로컬 `127.0.0.1`에 HTTP 서버를 열고 LINE의 `X-Line-Signature` 검증, 이벤트 중복 제거, 사용자별 큐를 처리합니다.
12
12
  - **Cloudflare Tunnel**: 원클릭 quick tunnel(`*.trycloudflare.com`, Cloudflare 계정 불필요) 또는 Cloudflare API 토큰을 붙여 넣어 이름 있는 터널+DNS를 만들고 고정 URL을 얻습니다. URL이 준비되면 webhook 엔드포인트를 LINE에 다시 쓰고 `webhook/test`를 실행합니다.
13
13
  - **LINE 자격 증명**: Channel ID와 Channel Secret을 입력하면 access token을 자동 발급합니다(`/v2/oauth/accessToken`, 약 30일, 만료 전 자동 갱신). 기존 Channel Access Token을 붙여 넣어도 됩니다.
14
- - **페르소나**: 패널에서 大肥魚 플러그인의 내장/커스텀 페르소나를 고르거나, 플러그인 내장 커스텀 페르소나(`<DSH_HOME>/line-bot/personas.json`, 추가/편집/가져오기)를 쓸 수 있습니다. 페르소나는 세션 단위로 지정되며 GUI의 다른 대화에는 영향을 주지 않습니다.
14
+ - **大肥魚 플러그인이 없을 때**: 플러그인 내장 번체 중국어 버전(大肥魚/阿茶/聽瀾)으로 자동 전환됩니다. 같은 `personas.json`에 저장되며 커스텀 페르소나처럼 편집할 수 있습니다. 大肥魚를 다시 설치하면 자동으로 원래 페르소나로 돌아갑니다.
15
15
  - **능동 메시지**: 플러그인 내장 스케줄러(launchd 불필요). 사용자마다 독립적인 무작위 카운트다운(최소~최대 간격)을 가지며, 시간이 되고 상대의 침묵이 충분히 길 때만 말을 겁니다. 최소/최대 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 연속 미응답 상한(도달 시 일시 중지, 상대가 답하면 초기화), 대상 사용자, 프롬프트를 설정할 수 있습니다. Agent가 지금은 아니라고 판단하면 `SKIP`을 반환해 건너뜁니다.
16
16
  - **분할 답장**: 답장을 빈 줄(문단) 또는 줄바꿈마다 최대 5개의 LINE 메시지로 나눠 보낼 수 있습니다. 하나의 긴 말풍선에 몰아넣지 않습니다. 패널에서 방식을 바꾸거나 끌 수 있습니다.
17
17
  - **전체 설정 패널**: 모든 설정이 "설정 → LINE Bot"에 있으며 `<DSH_HOME>/line-bot/config.json`(모드 600)에 저장됩니다.
@@ -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,14 @@ 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
+
225
234
  ## 진단
226
235
  - 패널의 "고급 설정 → 진단 실행"에서 서버, Tunnel, 공개 URL, webhook, dsh-meme 라이브러리, 이미지 변환 도구, 모델, 최근 오류를 확인할 수 있습니다. "안전 복구"는 tunnel 재연결과 webhook 재설정만 수행합니다.
227
236
  - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
@@ -256,4 +265,4 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
256
265
 
257
266
  - host 로직: `lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`).
258
267
  - 패널: `client.js`(`window.__ModuleLoader__`로 `settings.section` 등록).
259
- - 진입점: `index.js`(`/api/line-bot` 마운트, dev reload, 언로드 정리).
268
+ - 진입점: `index.js`(`/api/line-bot` 마운트, dev reload, 언로드 정리).
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  - **Webhook 伺服器**:在本機 `127.0.0.1` 開一個 HTTP server,處理 LINE 的 `X-Line-Signature` 驗章、事件去重、每位使用者各自的佇列。
12
12
  - **Cloudflare Tunnel**:一鍵開 quick tunnel(`*.trycloudflare.com`,免 Cloudflare 帳號),或貼上 Cloudflare API Token 建立具名隧道+DNS,取得固定網址;取得網址後自動把 webhook 端點寫回 LINE 並跑一次 `webhook/test`。
13
13
  - **LINE 憑證**:填 Channel ID+Channel Secret 就能自動簽發 access token(`/v2/oauth/accessToken`,約 30 天,快到期會自動續簽);也可以直接貼上既有的 Channel Access Token。
14
- - **人設**:面板可選大肥魚插件裡任何一個內建/自訂人設,或插件內建的自訂人設(存在 `<DSH_HOME>/line-bot/personas.json`,可新增/編輯/匯入)。人設是「逐 session」指定,不會改到你 GUI 裡其他對話的人設。
14
+ - **沒有大肥魚外掛時**:自動改用插件內建的繁體中文版本(大肥魚/阿茶/聽瀾),存在同一個 `personas.json`,可以像自訂人設一樣點開編輯;裝回大肥魚外掛後會自動切回外掛的人設。
15
15
  - **主動傳訊**:插件內建排程器(不用 launchd),每位使用者各自倒數(最短~最長間隔隨機);可調最短/最長間隔、每日/每月上限、兩則間距、安靜時段、對方安靜多久才可打擾、連續未回覆上限(達上限暫停,對方回話後歸零)、指定對象與提示詞;Agent 覺得不適合時可以回 `SKIP` 跳過。
16
16
  - **分段回覆**:可把回覆依空行(段落)或每個換行拆成多個 LINE 訊息框,最多 5 則,不再全部擠在同一則;面板可切換分段方式或關閉。
17
17
  - **完整設定面板**:所有設定都在「設定 → LINE Bot」裡,存到 `<DSH_HOME>/line-bot/config.json`(權限 600)。
@@ -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,14 @@ 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
+ - 合成需要約 20–40 秒;若 reply token 過期會自動改用 push。語音檔存在 `<DSH_HOME>/line-bot/media/`,透過 tunnel 的 `/media/<token>` 提供。
234
+ - 需要 `ffmpeg`;MLX 模式需要 Python 3.10+。
235
+
227
236
  ## 診斷與修復
228
237
  - 面板「進階設定 → 執行診斷」會檢查伺服器、Tunnel、公開網址、webhook、表情包圖庫、轉圖工具、模型與最近錯誤;「自動修復(安全)」只會重連 tunnel、重設 webhook。
229
238
  - 終端機:`npx dsh-line-bot doctor`、`npx dsh-line-bot doctor --fix`、`doctor --json`。
@@ -258,4 +267,4 @@ Bot 被加進群組(或多人聊天室)後:
258
267
 
259
268
  - host 邏輯:`lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`)。
260
269
  - 面板:`client.js`(`window.__ModuleLoader__` 註冊 `settings.section`)。
261
- - 進入點:`index.js`(掛 `/api/line-bot`、dev reload、卸載清理)。
270
+ - 進入點:`index.js`(掛 `/api/line-bot`、dev reload、卸載清理)。
package/README.zh-CN.md CHANGED
@@ -11,7 +11,7 @@
11
11
  - **Webhook 服务器**:在本机 `127.0.0.1` 开一个 HTTP server,处理 LINE 的 `X-Line-Signature` 验签、事件去重、每位用户各自的队列。
12
12
  - **Cloudflare Tunnel**:一键开 quick tunnel(`*.trycloudflare.com`,免 Cloudflare 账号),或贴入 Cloudflare API Token 创建具名隧道+DNS,取得固定网址;取得网址后自动把 webhook 端点写回 LINE 并跑一次 `webhook/test`。
13
13
  - **LINE 凭证**:填 Channel ID+Channel Secret 就能自动签发 access token(`/v2/oauth/accessToken`,约 30 天,快到期会自动续签);也可以直接贴入既有的 Channel Access Token。
14
- - **人设**:面板可选大肥鱼插件里任何一个内置/自定义人设,或插件内置的自定义人设(存在 `<DSH_HOME>/line-bot/personas.json`,可新增/编辑/导入)。人设是「逐 session」指定,不会改到你 GUI 里其他对话的人设。
14
+ - **没有大肥鱼插件时**:自动改用插件内置的繁体中文版本(大肥鱼/阿茶/听澜),存在同一个 `personas.json`,可以像自定义人设一样点开编辑;装回大肥鱼插件后会自动切回插件的人设。
15
15
  - **主动传讯**:插件内置排程器(不用 launchd),每位使用者各自倒数(最短~最长间隔随机);可调最短/最长间隔、每日/每月上限、两则间距、安静时段、对方安静多久才可打扰、连续未回复上限(达上限暂停,对方回话后归零)、指定对象与提示词;Agent 觉得不适合时可以回 `SKIP` 跳过。
16
16
  - **分段回复**:可把回复依空行(段落)或每个换行拆成多个 LINE 消息框,最多 5 条,不再全部挤在同一条;面板可切换分段方式或关闭。
17
17
  - **完整设置面板**:所有设置都在「设置 → LINE Bot」里,存到 `<DSH_HOME>/line-bot/config.json`(权限 600)。
@@ -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,14 @@ 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
+ - 合成需要约 20–40 秒;若 reply token 过期会自动改用 push。语音文件存在 `<DSH_HOME>/line-bot/media/`,通过 tunnel 的 `/media/<token>` 提供。
234
+ - 需要 `ffmpeg`;MLX 模式需要 Python 3.10+。
235
+
227
236
  ## 诊断与修复
228
237
  - 面板「高级设置 → 执行诊断」会检查服务器、Tunnel、公开网址、webhook、表情包图库、转图工具、模型与最近错误;「自动修复(安全)」只会重连 tunnel、重设 webhook。
229
238
  - 终端:`npx dsh-line-bot doctor`、`npx dsh-line-bot doctor --fix`、`doctor --json`。
@@ -258,4 +267,4 @@ Bot 被加入群组(或多人聊天室)后:
258
267
 
259
268
  - host 逻辑:`lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`)。
260
269
  - 面板:`client.js`(`window.__ModuleLoader__` 注册 `settings.section`)。
261
- - 进入点:`index.js`(挂 `/api/line-bot`、dev reload、卸载清理)。
270
+ - 进入点:`index.js`(挂 `/api/line-bot`、dev reload、卸载清理)。
@@ -32,6 +32,12 @@ 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
+
35
41
  ### APNG 不會動
36
42
  - LINE 不播放 APNG,只顯示第一幀。保持 `stickers.apng=false` 即可。
37
43
 
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
 
@@ -634,7 +637,10 @@ window.__ModuleLoader__.load({
634
637
  }
635
638
 
636
639
  async function removeLocalPersona(item) {
637
- if (typeof window !== 'undefined' && !window.confirm(`刪除自訂人設「${item.name}」?`)) return
640
+ const message = item.builtin
641
+ ? `刪除內建人設「${item.name}」?沒有大肥魚外掛時就沒有這份繁中版本可用(重新載入插件才會補回)。`
642
+ : `刪除自訂人設「${item.name}」?`
643
+ if (typeof window !== 'undefined' && !window.confirm(message)) return
638
644
  await act('local-persona-remove', { id: item.id })
639
645
  if (localEdit.id === item.id) setLocalEdit({ id: '', name: '', text: '' })
640
646
  }
@@ -694,6 +700,91 @@ window.__ModuleLoader__.load({
694
700
  await act('tieba-bars-add', {})
695
701
  }
696
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
+
697
788
  async function refresh() {
698
789
  setToast(null)
699
790
  try {
@@ -754,6 +845,9 @@ window.__ModuleLoader__.load({
754
845
  const memoryStatus = status.memory || {}
755
846
  const agentStatus = status.agent || {}
756
847
  const modelProviders = Array.isArray(modelCatalog?.providers) ? modelCatalog.providers : []
848
+ const voiceCfg = cfg.voice || {}
849
+ const voiceStatus = status.voice || {}
850
+ const voiceSetup = voiceStatus.setup || {}
757
851
  const memoryUsers = memoryData?.users || []
758
852
  const syncStats = status.sync || {}
759
853
  const runtime = status
@@ -763,6 +857,7 @@ window.__ModuleLoader__.load({
763
857
  const personaCatalog = Array.isArray(persona.catalog) && persona.catalog.length
764
858
  ? persona.catalog
765
859
  : [...personaOptions, ...personaLocal]
860
+ const personaLocalSelectable = personaLocal.filter((item) => !item.builtin || persona.bridgeAvailable === false)
766
861
  const selectedPersona = personaCatalog.find((item) => item.id === personaCfg.id) || null
767
862
  const selectedName = selectedPersona?.name || personaCfg.id || '大肥魚'
768
863
  const selectedIsLocal = String(personaCfg.id || '').startsWith('local:')
@@ -1133,9 +1228,9 @@ window.__ModuleLoader__.load({
1133
1228
  personaOptions.filter((item) => item.enabled !== false).map((item) =>
1134
1229
  h('option', { key: item.id, value: item.id }, `${item.name}${item.builtin ? '(內建)' : ''} · ${item.chars} 字`)))
1135
1230
  : null,
1136
- personaLocal.length
1137
- ? h('optgroup', { label: '插件自訂' },
1138
- personaLocal.map((item) => h('option', { key: item.id, value: item.id }, `${item.name} · ${item.chars} 字`)))
1231
+ personaLocalSelectable.length
1232
+ ? h('optgroup', { label: '插件自訂/內建' },
1233
+ personaLocalSelectable.map((item) => h('option', { key: item.id, value: item.id }, `${item.name}${item.builtin ? '(內建)' : ''} · ${item.chars} 字`)))
1139
1234
  : null,
1140
1235
  (!personaCatalog.length)
1141
1236
  ? h('option', { value: personaCfg.id || '' }, selectedName)
@@ -1143,7 +1238,9 @@ window.__ModuleLoader__.load({
1143
1238
  h(Field, { label: '人設來源' },
1144
1239
  h('div', { style: { fontSize: 12, paddingTop: 4 } },
1145
1240
  selectedIsLocal
1146
- ? '插件自訂人設:每一輪由插件注入 system prompt,不需要任何外掛。'
1241
+ ? (selectedPersona?.builtin
1242
+ ? '插件內建繁體人設:沒有大肥魚外掛時使用,每一輪由插件注入 system prompt,可直接編輯。'
1243
+ : '插件自訂人設:每一輪由插件注入 system prompt,不需要任何外掛。')
1147
1244
  : `大肥魚 · ${persona.mode || '未知'}${persona.mode === 'session' ? '(可逐 session 指定)' : '(目前不是 session 模式,指定可能無效)'}`)),
1148
1245
  ),
1149
1246
  h(Toggle, { checked: personaCfg.enforce !== false, onChange: (value) => update({ persona: { enforce: value } }), label: '強制使用選擇的人設', hint: '關掉=完全不套用人設(連大肥魚也不指定)' }),
@@ -1156,8 +1253,8 @@ window.__ModuleLoader__.load({
1156
1253
  ),
1157
1254
  h('div', { style: S.hint }, '切換 session 模式會改變大肥魚插件在全域的行為(每個 session 可各自指定人設),這是它官方支援的設定。'),
1158
1255
  h('div', { style: { ...S.panelBox, marginTop: 4 } },
1159
- h('div', { style: S.fieldTitle }, '自訂人設(插件內建,不依賴外掛)'),
1160
- h('div', { style: S.hint }, `存在 ${persona.localPath || '<資料夾>/personas.json'}。可以新增/編輯/刪除,或把目前的大肥魚人設複製一份過來再改。`),
1256
+ h('div', { style: S.fieldTitle }, '自訂與內建人設(插件內建,不依賴外掛)'),
1257
+ h('div', { style: S.hint }, `存在 ${persona.localPath || '<資料夾>/personas.json'}。沒有大肥魚外掛時,大肥魚/阿茶/聽瀾會自動改用這裡的繁體中文版本;可以新增、編輯、刪除,或把目前的大肥魚人設複製一份過來再改。`),
1161
1258
  h('div', { style: S.grid },
1162
1259
  h(Field, { label: '名稱' }, h(Text, { value: localEdit.name, onChange: (value) => setLocalEdit((prev) => ({ ...prev, name: value })), placeholder: '例如:我的助手' })),
1163
1260
  ),
@@ -1172,7 +1269,7 @@ window.__ModuleLoader__.load({
1172
1269
  ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 } },
1173
1270
  personaLocal.map((item) =>
1174
1271
  h('div', { key: item.id, style: { ...S.row, justifyContent: 'space-between' } },
1175
- h('span', { style: { fontSize: 12 } }, `${item.name}(${item.chars} 字)`),
1272
+ h('span', { style: { fontSize: 12 } }, `${item.name}${item.builtin ? '(內建)' : ''}(${item.chars} 字)`),
1176
1273
  h('div', { style: S.row },
1177
1274
  h(Btn, { onClick: () => editLocalPersona(item), disabled: !!busy }, '編輯'),
1178
1275
  h(Btn, { onClick: () => removeLocalPersona(item), disabled: !!busy, tone: 'danger' }, '刪除')))))
@@ -1311,10 +1408,48 @@ window.__ModuleLoader__.load({
1311
1408
  ),
1312
1409
  ),
1313
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.mlx?.ok ? 'macOS MLX' : voiceStatus.gguf?.ok ? 'GGUF' : '未安裝引擎'),
1419
+ h(Badge, { ok: !!voiceStatus.reference?.path }, voiceStatus.reference?.path ? ('參考音 ' + (voiceStatus.reference.seconds || 0) + ' 秒') : '沒有參考音'),
1420
+ ),
1421
+ h('div', { style: S.hint }, 'macOS 會用 MLX(較快);Windows 或沒有 MLX 時用 CosyVoice GGUF。合成一次約需 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
+
1314
1449
  // 6. 使用者
1315
1450
  h(
1316
1451
  Card,
1317
- { id: sectionDomId('users'), order: 12, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1452
+ { id: sectionDomId('users'), order: 13, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1318
1453
  (access.pending || []).length > 0
1319
1454
  ? h('div', { style: S.panelBox },
1320
1455
  h('div', { style: S.fieldTitle }, `待審核(${access.pending.length})`),
@@ -1364,7 +1499,7 @@ window.__ModuleLoader__.load({
1364
1499
  // 6.5 群組
1365
1500
  h(
1366
1501
  Card,
1367
- { id: sectionDomId('group'), order: 13, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session、工作資料夾、人設與記憶;比較晚加入時可以匯入 LINE 匯出的聊天記錄補記憶。' },
1502
+ { id: sectionDomId('group'), order: 14, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session、工作資料夾、人設與記憶;比較晚加入時可以匯入 LINE 匯出的聊天記錄補記憶。' },
1368
1503
  h('input', { ref: importInput, type: 'file', accept: '.txt,text/plain', style: { display: 'none' }, onChange: importChatFile }),
1369
1504
  h('div', { style: S.grid },
1370
1505
  h(Field, { label: '沒被點名時的回覆機率(%)', hint: '每則群組訊息用這個機率決定要不要回;0 就只在被 @ 或引用時回' },