dsh-line-bot 0.10.0 → 0.10.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 0.10.2
2
+
3
+ - 修正 Windows 一鍵配置語音環境:改為直接使用解壓後的 `crispasr.exe`(與 `openblas.dll` 同資料夾),不再只複製 exe 導致缺少 DLL、`--version` 失敗。
4
+ - 安裝時會實際執行 `crispasr.exe --version` 驗證,失敗時回報缺少 DLL 的細節。
5
+ - 修正面板卡在「讀取中」:語音環境偵測改為快取+只用檔案檢查,`/api/line-bot/status` 不再因 MLX 匯入檢查被阻塞(先前每次可能卡 30 秒以上)。
6
+
7
+ ## 0.10.1
8
+
9
+ - 語音:自然語言語氣(instruct)改用 CosyVoice GGUF;MLX 的 instruct 模式會把語氣指令一起念出來,已避免。
10
+ - 語音:MLX 合成加上 `NUMBA_CACHE_DIR`,避免新版 Python 環境第一次執行就失敗。
11
+ - 面板與文件更新語音引擎說明。
12
+
1
13
  ## 0.10.0
2
14
 
3
15
  - 新增語音回覆(CosyVoice3-0.5B):角色可以在回覆最後加 `[[voice]]`,插件會把文字合成語音並用 LINE 語音訊息送出;合成失敗或 reply token 過期時自動改用文字/push。
package/README.en.md CHANGED
@@ -230,6 +230,7 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
230
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
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
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.
233
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.
234
235
  - Requires `ffmpeg`; MLX mode needs Python 3.10+.
235
236
 
package/README.ja.md CHANGED
@@ -233,6 +233,9 @@ Bot がグループ(または複数人のルーム)に追加されたとき
233
233
  - 合成には約 20–40 秒かかります。reply token の期限が切れた場合は push に切り替わります。音声は `<DSH_HOME>/line-bot/media/` に保存され、tunnel の `/media/<token>` で配信されます。
234
234
  - `ffmpeg` が必要です。MLX モードは Python 3.10+ が必要です。
235
235
 
236
+ - 自然言語の話し方(instruct)は CosyVoice GGUF を使用します。MLX はゼロショット専用で、指示が読み上げられるのを防ぎます。
237
+
238
+
236
239
  ## 診断
237
240
  - パネルの「詳細設定 → 診断を実行」でサーバー、Tunnel、公開 URL、webhook、dsh-meme 図庫、画像変換ツール、モデル、最近のエラーを確認できます。「安全な修復」は tunnel 再接続と webhook 再設定だけを行います。
238
241
  - CLI:`npx dsh-line-bot doctor`、`doctor --fix`、`doctor --json`。
package/README.ko.md CHANGED
@@ -231,6 +231,9 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
231
231
  - 합성에는 약 20–40초가 걸립니다. reply token이 만료되면 push로 자동 전환됩니다. 음성은 `<DSH_HOME>/line-bot/media/`에 저장되고 tunnel의 `/media/<token>`으로 제공됩니다.
232
232
  - `ffmpeg`가 필요합니다. MLX 모드는 Python 3.10+가 필요합니다.
233
233
 
234
+ - 자연어 말투(instruct)는 CosyVoice GGUF를 사용합니다. MLX는 제로샷 전용이라 지시문이 읽히지 않습니다.
235
+
236
+
234
237
  ## 진단
235
238
  - 패널의 "고급 설정 → 진단 실행"에서 서버, Tunnel, 공개 URL, webhook, dsh-meme 라이브러리, 이미지 변환 도구, 모델, 최근 오류를 확인할 수 있습니다. "안전 복구"는 tunnel 재연결과 webhook 재설정만 수행합니다.
236
239
  - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
package/README.md CHANGED
@@ -230,6 +230,7 @@ Bot 被加進群組(或多人聊天室)後:
230
230
  - 角色可以在回覆最後加 `[[voice]]`,插件會用 CosyVoice3-0.5B 把文字合成語音,並以 LINE 語音訊息送出(m4a)。
231
231
  - macOS(Apple Silicon)在本機用 MLX 合成;Windows/沒有 MLX 時用 CosyVoice GGUF(CrispASR)。第一次要先在面板按「一鍵配置環境」(會下載模型,約 1–2GB)。
232
232
  - 面板「語音回覆」可開關、確認聲音使用權、上傳參考音(預設用指定音檔的 12–16 秒片段)、選推理模式與語氣指令、試聽。
233
+ - 自然語言語氣(instruct)使用 CosyVoice GGUF;MLX 只用於零樣本模式,避免語氣指令被念出來。
233
234
  - 合成需要約 20–40 秒;若 reply token 過期會自動改用 push。語音檔存在 `<DSH_HOME>/line-bot/media/`,透過 tunnel 的 `/media/<token>` 提供。
234
235
  - 需要 `ffmpeg`;MLX 模式需要 Python 3.10+。
235
236
 
package/README.zh-CN.md CHANGED
@@ -230,6 +230,7 @@ Bot 被加入群组(或多人聊天室)后:
230
230
  - 角色可以在回复最后加 `[[voice]]`,插件会用 CosyVoice3-0.5B 把文字合成语音,并以 LINE 语音消息发送(m4a)。
231
231
  - macOS(Apple Silicon)在本机用 MLX 合成;Windows/没有 MLX 时用 CosyVoice GGUF(CrispASR)。第一次要先在面板按「一键配置环境」(会下载模型,约 1–2GB)。
232
232
  - 面板「语音回复」可开关、确认声音使用权、上传参考音频(默认用指定音频的 12–16 秒片段)、选择推理模式与语气指令、试听。
233
+ - 自然语言语气(instruct)使用 CosyVoice GGUF;MLX 只用于零样本模式,避免语气指令被念出来。
233
234
  - 合成需要约 20–40 秒;若 reply token 过期会自动改用 push。语音文件存在 `<DSH_HOME>/line-bot/media/`,通过 tunnel 的 `/media/<token>` 提供。
234
235
  - 需要 `ffmpeg`;MLX 模式需要 Python 3.10+。
235
236
 
@@ -38,6 +38,14 @@ npx dsh-line-bot doctor --fix # 安全修復:重連 tunnel、重設 webhook
38
38
  - 合成約需 20–40 秒;reply token 過期時會自動改用 push。日誌看 `語音回覆`、`語音合成失敗`。
39
39
  - 模型只有在語言上下文有語音提示時才會加 `[[voice]]`;可先用「測試 Agent 回覆」確認。
40
40
 
41
+ ### Windows 一鍵配置語音失敗
42
+ - 0.10.2 起會直接使用解壓後的 `crispasr.exe`,它必須和 `openblas.dll` 在同一個資料夾。
43
+ - 若仍失敗,到 `%USERPROFILE%\.dsh\line-bot\tts\crispasr\windows\` 找 `crispasr-windows-x86_64-cpu\crispasr.exe`,手動雙擊或執行 `crispasr.exe --version` 看缺哪個 DLL。
44
+ - 也需要 `ffmpeg` 在 PATH 中。
45
+
46
+ ### 語音把語氣指令念出來
47
+ - 舊版 MLX 的 instruct 模式會把語氣描述一起念;0.10.1 起 instruct 一律走 CosyVoice GGUF。升級後重新產生一次語音即可,舊音檔不會變。
48
+
41
49
  ### APNG 不會動
42
50
  - LINE 不播放 APNG,只顯示第一幀。保持 `stickers.apng=false` 即可。
43
51
 
package/client.js CHANGED
@@ -1415,10 +1415,10 @@ window.__ModuleLoader__.load({
1415
1415
  h('div', { style: S.row },
1416
1416
  h(Badge, { ok: voiceStatus.ready === true }, voiceStatus.ready ? '語音環境就緒' : '尚未就緒'),
1417
1417
  h(Badge, { ok: voiceCfg.consent === true }, voiceCfg.consent ? '已確認使用權' : '未確認使用權'),
1418
- h(Badge, { ok: true }, voiceStatus.mlx?.ok ? 'macOS MLX' : voiceStatus.gguf?.ok ? 'GGUF' : '未安裝引擎'),
1418
+ h(Badge, { ok: true }, voiceStatus.effectiveProvider === 'gguf' ? 'GGUF' : voiceStatus.effectiveProvider === 'mlx' ? 'MLX' : '未安裝引擎'),
1419
1419
  h(Badge, { ok: !!voiceStatus.reference?.path }, voiceStatus.reference?.path ? ('參考音 ' + (voiceStatus.reference.seconds || 0) + ' 秒') : '沒有參考音'),
1420
1420
  ),
1421
- h('div', { style: S.hint }, 'macOS 會用 MLX(較快);Windows 或沒有 MLX 時用 CosyVoice GGUF。合成一次約需 20–40 秒,送出時若 reply token 過期會自動改用 push。'),
1421
+ h('div', { style: S.hint }, '自然語言語氣(instruct)一律用 CosyVoice GGUF,避免 MLX 把語氣指令念出來;零樣本模式可用 MLX。合成一次約需 20–40 秒,reply token 過期會自動改用 push。'),
1422
1422
  h(Toggle, { checked: voiceCfg.enabled === true, onChange: (value) => update({ voice: { enabled: value } }), label: '開啟語音回覆', hint: '開啟後角色可以自己決定用語音;關閉時完全不合成。' }),
1423
1423
  h(Toggle, { checked: voiceCfg.consent === true, onChange: (value) => update({ voice: { consent: value } }), label: '我確認有使用此聲音的權利', hint: '使用他人聲音做克隆前請先取得同意;確認後才會合成。' }),
1424
1424
  h('div', { style: S.row },
package/lib/host.mjs CHANGED
@@ -51,7 +51,7 @@ const { createVision } = visionMod
51
51
  const { createTts } = ttsMod
52
52
  const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep, readJsonSafe } = utilMod
53
53
 
54
- export const VERSION = '0.10.0'
54
+ export const VERSION = '0.10.2'
55
55
 
56
56
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
57
57
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -2801,7 +2801,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2801
2801
  return { messages: built.messages, parsed: built.parsed }
2802
2802
  }
2803
2803
  case 'voice-detect':
2804
- return tts.detect()
2804
+ return tts.detect({ force: true, validate: true })
2805
2805
  case 'voice-install': {
2806
2806
  if (tts.status().installing) return { started: false, already: true }
2807
2807
  tts.install()
package/lib/tts.mjs CHANGED
@@ -94,8 +94,13 @@ export function createTts({ dir, logger, getConfig }) {
94
94
  last: null,
95
95
  lastError: '',
96
96
  installing: false,
97
+ ggufBinary: '',
97
98
  }
98
99
 
100
+ let detectCache = { at: 0, value: null }
101
+ let refCache = { at: 0, key: '', value: null }
102
+ let mlxError = ''
103
+
99
104
  function setSetup(phase, message, error = '') {
100
105
  state.setup = {
101
106
  phase,
@@ -126,6 +131,15 @@ export function createTts({ dir, logger, getConfig }) {
126
131
  function referenceInfo() {
127
132
  const file = referencePath()
128
133
  if (!file) return { path: '', exists: false, seconds: 0 }
134
+ let stamp = ''
135
+ try {
136
+ const st = fs.statSync(file)
137
+ stamp = `${st.size}:${st.mtimeMs}`
138
+ } catch {
139
+ stamp = ''
140
+ }
141
+ const key = `${file}|${stamp}`
142
+ if (refCache.value && refCache.key === key && Date.now() - refCache.at < 60000) return refCache.value
129
143
  let seconds = 0
130
144
  try {
131
145
  const out = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file], {
@@ -136,7 +150,9 @@ export function createTts({ dir, logger, getConfig }) {
136
150
  } catch {
137
151
  seconds = 0
138
152
  }
139
- return { path: file, exists: true, seconds: Math.round(seconds * 100) / 100 }
153
+ const value = { path: file, exists: true, seconds: Math.round(seconds * 100) / 100 }
154
+ refCache = { at: Date.now(), key, value }
155
+ return value
140
156
  }
141
157
 
142
158
  function mlxModelCached() {
@@ -156,8 +172,20 @@ export function createTts({ dir, logger, getConfig }) {
156
172
  return false
157
173
  }
158
174
 
159
- let mlxError = ''
160
- function mlxReady() {
175
+ function sitePackages(name) {
176
+ try {
177
+ const lib = path.join(paths.mlxVenv, 'lib')
178
+ for (const dir of fs.readdirSync(lib)) {
179
+ if (!dir.startsWith('python')) continue
180
+ if (dirExists(path.join(lib, dir, 'site-packages', name))) return true
181
+ }
182
+ } catch {
183
+ /* ignore */
184
+ }
185
+ return false
186
+ }
187
+
188
+ function mlxReady({ validate = false } = {}) {
161
189
  if (!fileExists(paths.mlxPython)) {
162
190
  mlxError = '找不到 MLX Python 環境'
163
191
  return false
@@ -166,8 +194,16 @@ export function createTts({ dir, logger, getConfig }) {
166
194
  mlxError = `找不到 MLX 合成腳本:${paths.mlxScript}`
167
195
  return false
168
196
  }
197
+ if (!sitePackages('mlx_audio') || !sitePackages('mlx_lm')) {
198
+ mlxError = 'MLX 套件尚未安裝完成'
199
+ return false
200
+ }
201
+ if (!validate) {
202
+ mlxError = ''
203
+ return true
204
+ }
169
205
  try {
170
- execFileSync(paths.mlxPython, ['-c', 'import mlx_audio, mlx_lm, librosa'], { stdio: 'ignore', timeout: 30000 })
206
+ execFileSync(paths.mlxPython, ['-c', 'import mlx_audio, mlx_lm, librosa'], { stdio: 'ignore', timeout: 20000 })
171
207
  mlxError = ''
172
208
  return true
173
209
  } catch (error) {
@@ -176,14 +212,39 @@ export function createTts({ dir, logger, getConfig }) {
176
212
  }
177
213
  }
178
214
 
215
+ function findCrispExe(dir, depth = 3) {
216
+ if (depth < 0) return ''
217
+ let entries = []
218
+ try {
219
+ entries = fs.readdirSync(dir, { withFileTypes: true })
220
+ } catch {
221
+ return ''
222
+ }
223
+ for (const entry of entries) {
224
+ if (entry.isFile() && /^crispasr\.exe$/i.test(entry.name)) return path.join(dir, entry.name)
225
+ }
226
+ for (const entry of entries) {
227
+ if (!entry.isDirectory()) continue
228
+ const hit = findCrispExe(path.join(dir, entry.name), depth - 1)
229
+ if (hit) return hit
230
+ }
231
+ return ''
232
+ }
233
+
179
234
  function ggufBinary() {
180
- const candidates = [isWin() ? paths.winCrisp : paths.macCrisp, which('crispasr')]
235
+ if (state.ggufBinary && fileExists(state.ggufBinary)) return state.ggufBinary
236
+ if (isWin()) {
237
+ const found = findCrispExe(paths.winDir)
238
+ if (found) return found
239
+ }
240
+ const candidates = [isWin() ? '' : paths.macCrisp, which('crispasr')]
181
241
  return candidates.find((item) => item && fileExists(item)) || ''
182
242
  }
183
243
 
184
- function ggufReady() {
244
+ function ggufReady({ validate = false } = {}) {
185
245
  const bin = ggufBinary()
186
246
  if (!bin) return false
247
+ if (!validate) return true
187
248
  try {
188
249
  execFileSync(bin, ['--version'], { stdio: 'ignore', timeout: 8000 })
189
250
  return true
@@ -192,19 +253,23 @@ export function createTts({ dir, logger, getConfig }) {
192
253
  }
193
254
  }
194
255
 
195
- function detect() {
256
+ function detect({ force = false, validate = false } = {}) {
257
+ const now = Date.now()
258
+ if (!force && !validate && detectCache.value && now - detectCache.at < 120000) return detectCache.value
196
259
  const platform = process.platform
197
260
  const arch = process.arch
198
261
  const preferred = platform === 'darwin' && arch === 'arm64' ? 'mlx' : 'gguf'
199
262
  const ffmpeg = which('ffmpeg')
200
263
  const ref = referenceInfo()
201
- return {
264
+ const mlxOk = mlxReady({ validate })
265
+ const ggufOk = ggufReady({ validate })
266
+ const value = {
202
267
  platform,
203
268
  arch,
204
269
  preferred,
205
270
  ffmpeg: { path: ffmpeg, ok: !!ffmpeg },
206
271
  mlx: {
207
- ok: mlxReady(),
272
+ ok: mlxOk,
208
273
  python: fileExists(paths.mlxPython) ? paths.mlxPython : '',
209
274
  model: MLX_MODEL,
210
275
  modelCached: mlxModelCached(),
@@ -212,19 +277,33 @@ export function createTts({ dir, logger, getConfig }) {
212
277
  script: paths.mlxScript,
213
278
  },
214
279
  gguf: {
215
- ok: ggufReady(),
280
+ ok: ggufOk,
216
281
  binary: ggufBinary(),
217
282
  backend: GGUF_BACKEND,
218
283
  },
219
284
  reference: ref,
220
285
  setup: state.setup,
221
- ready: (preferred === 'mlx' ? mlxReady() : ggufReady()) && !!ref.path,
286
+ ready: (mlxOk || ggufOk) && !!ref.path,
222
287
  }
288
+ detectCache = { at: now, value }
289
+ return value
290
+ }
291
+
292
+ function providerFor(cfg = cfgVoice()) {
293
+ const detected = detect()
294
+ const instruct = String(cfg.cloneMode || 'instruct') !== 'zero-shot'
295
+ if (instruct) return detected.gguf.ok ? 'gguf' : ''
296
+ return detected.mlx.ok ? 'mlx' : detected.gguf.ok ? 'gguf' : ''
223
297
  }
224
298
 
225
299
  function resolveProvider(cfg = cfgVoice()) {
226
300
  const detected = detect()
227
301
  const wanted = String(cfg.provider || 'auto')
302
+ const instruct = String(cfg.cloneMode || 'instruct') !== 'zero-shot'
303
+ if (instruct) {
304
+ if (detected.gguf.ok) return 'gguf'
305
+ throw new Error('自然語言語氣(instruct)模式需要 GGUF 引擎;MLX 的 instruct 會把指示念出來。請按「一鍵配置環境」。')
306
+ }
228
307
  if (wanted === 'mlx') {
229
308
  if (!detected.mlx.ok) throw new Error('MLX 語音環境還沒安裝完成,請先按「一鍵配置環境」')
230
309
  return 'mlx'
@@ -293,7 +372,7 @@ export function createTts({ dir, logger, getConfig }) {
293
372
  const pipBase = uv ? [uv, 'pip', 'install', '--python', paths.mlxPython] : [paths.mlxPython, '-m', 'pip', 'install']
294
373
  setSetup('pip', '安裝 MLX 語音套件(mlx-audio-plus)')
295
374
  const packages = ['mlx-audio-plus==0.1.7', 'mlx-lm==0.29.1', 'librosa', 'pyloudnorm', 'soundfile']
296
- await run(pipBase[0], [...pipBase.slice(1), ...packages], { timeout: 1800000, env: { ...process.env, HF_HOME: paths.hfHome } })
375
+ await run(pipBase[0], [...pipBase.slice(1), ...packages], { timeout: 1800000, env: { ...process.env, HF_HOME: paths.hfHome, NUMBA_CACHE_DIR: path.join(paths.root, 'numba') } })
297
376
 
298
377
  if (!mlxModelCached()) {
299
378
  setSetup('model', '下載 CosyVoice3-0.5B 模型(約 1.4GB,第一次會比較久)')
@@ -303,7 +382,7 @@ export function createTts({ dir, logger, getConfig }) {
303
382
  ].join('\n')
304
383
  await run(paths.mlxPython, ['-c', downloader], {
305
384
  timeout: 3600000,
306
- env: { ...process.env, HF_HOME: paths.hfHome },
385
+ env: { ...process.env, HF_HOME: paths.hfHome, NUMBA_CACHE_DIR: path.join(paths.root, 'numba') },
307
386
  })
308
387
  } else {
309
388
  setSetup('model', 'MLX 模型已下載')
@@ -337,18 +416,15 @@ export function createTts({ dir, logger, getConfig }) {
337
416
  const zip = path.join(root, 'crispasr-windows.zip')
338
417
  await downloadFile(CRISPASR_WIN_URL, zip)
339
418
  await run('powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath ${JSON.stringify(zip)} -DestinationPath ${JSON.stringify(paths.winDir)} -Force`], { timeout: 300000 })
340
- const found = []
341
- for (const name of fs.readdirSync(paths.winDir)) {
342
- const full = path.join(paths.winDir, name)
343
- if (/crispasr(\.exe)?$/i.test(name)) found.push(full)
344
- if (dirExists(full)) {
345
- for (const sub of fs.readdirSync(full)) {
346
- if (/^crispasr(\.exe)?$/i.test(sub)) found.push(path.join(full, sub))
347
- }
348
- }
419
+ const exe = findCrispExe(paths.winDir)
420
+ if (!exe) throw new Error('解壓縮後找不到 crispasr.exe')
421
+ try {
422
+ execFileSync(exe, ['--version'], { encoding: 'utf8', timeout: 15000 })
423
+ } catch (error) {
424
+ const detail = String(error?.stderr || error?.message || error).slice(0, 300)
425
+ throw new Error(`crispasr.exe 無法執行(可能是缺少 DLL):${detail}`)
349
426
  }
350
- if (!found[0]) throw new Error('解壓縮後找不到 crispasr.exe')
351
- if (found[0] !== paths.winCrisp) fs.copyFileSync(found[0], paths.winCrisp)
427
+ state.ggufBinary = exe
352
428
  return 'gguf'
353
429
  }
354
430
 
@@ -398,10 +474,25 @@ export function createTts({ dir, logger, getConfig }) {
398
474
  }
399
475
  await ensureDefaultReference()
400
476
  const env = { ...process.env, HF_HOME: paths.hfHome }
401
- setSetup('test', '測試語音合成')
402
- await synthesize('主人,語音功能測試成功。', { forceProvider: provider, env, skipConsent: true })
403
- setSetup('done', `環境已就緒(${provider === 'mlx' ? 'macOS MLX' : 'CosyVoice GGUF'})`)
404
- return { started: true, provider }
477
+ if (detected.platform === 'darwin' && provider !== 'gguf') {
478
+ try {
479
+ await installMacGguf()
480
+ } catch (error) {
481
+ logger.warn(`GGUF 備援引擎安裝失敗:${String(error?.message || error).slice(0, 200)}`)
482
+ }
483
+ }
484
+ setSetup('test', '測試語音合成(instruct 模式會用 GGUF 引擎)')
485
+ await synthesize('主人,語音功能測試成功。', { env, skipConsent: true })
486
+ const finalProvider = providerFor() || provider
487
+ try {
488
+ fs.writeFileSync(path.join(root, '.ready'), new Date().toISOString())
489
+ } catch {
490
+ /* ignore */
491
+ }
492
+ detectCache = { at: 0, value: null }
493
+ refCache = { at: 0, key: '', value: null }
494
+ setSetup('done', `環境已就緒(${finalProvider === 'mlx' ? 'macOS MLX' : 'CosyVoice GGUF'})`)
495
+ return { started: true, provider: finalProvider }
405
496
  } catch (error) {
406
497
  setSetup('error', '環境安裝失敗', error)
407
498
  throw error
@@ -423,6 +514,11 @@ export function createTts({ dir, logger, getConfig }) {
423
514
  const cfg = cfgVoice()
424
515
  if (!skipConsent && cfg.consent !== true) throw new Error('尚未確認聲音使用權利(面板:語音回覆 → 我確認有使用權)')
425
516
  if (!skipConsent && cfg.enabled === false) throw new Error('語音功能未開啟')
517
+ if (String(cfg.cloneMode || 'instruct') !== 'zero-shot' && !ggufReady()) {
518
+ setSetup('engine', '下載 GGUF 引擎(自然語言語氣模式需要)')
519
+ if (isWin()) await installWinGguf()
520
+ else await installMacGguf()
521
+ }
426
522
  const provider = forceProvider || resolveProvider(cfg)
427
523
  const ref = referencePath()
428
524
  if (!ref) throw new Error('尚未設定參考音')
@@ -451,7 +547,7 @@ export function createTts({ dir, logger, getConfig }) {
451
547
  fs.writeFileSync(jobFile, JSON.stringify(payload), { mode: 0o600 })
452
548
  const { stdout } = await run(paths.mlxPython, [paths.mlxScript, jobFile], {
453
549
  timeout: 600000,
454
- env: { ...env, HF_HOME: paths.hfHome },
550
+ env: { ...env, HF_HOME: paths.hfHome, NUMBA_CACHE_DIR: path.join(paths.root, 'numba') },
455
551
  })
456
552
  const line = String(stdout || '').split(/\r?\n/).reverse().find((item) => item.startsWith('RESULT:'))
457
553
  if (!line) throw new Error(`MLX 沒有回傳結果:${String(stdout).slice(-300)}`)
@@ -524,6 +620,7 @@ export function createTts({ dir, logger, getConfig }) {
524
620
  installing: state.installing,
525
621
  last: state.last,
526
622
  lastError: state.lastError,
623
+ effectiveProvider: (() => { try { return providerFor() } catch { return '' } })(),
527
624
  mlxScript: fileExists(paths.mlxScript),
528
625
  lastSample: (() => {
529
626
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",