dsh-line-bot 0.10.1 → 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,9 @@
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
+
1
7
  ## 0.10.1
2
8
 
3
9
  - 語音:自然語言語氣(instruct)改用 CosyVoice GGUF;MLX 的 instruct 模式會把語氣指令一起念出來,已避免。
@@ -38,6 +38,11 @@ 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
+
41
46
  ### 語音把語氣指令念出來
42
47
  - 舊版 MLX 的 instruct 模式會把語氣描述一起念;0.10.1 起 instruct 一律走 CosyVoice GGUF。升級後重新產生一次語音即可,舊音檔不會變。
43
48
 
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.1'
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,14 +277,16 @@ 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: (mlxReady() || ggufReady()) && !!ref.path,
286
+ ready: (mlxOk || ggufOk) && !!ref.path,
222
287
  }
288
+ detectCache = { at: now, value }
289
+ return value
223
290
  }
224
291
 
225
292
  function providerFor(cfg = cfgVoice()) {
@@ -349,18 +416,15 @@ export function createTts({ dir, logger, getConfig }) {
349
416
  const zip = path.join(root, 'crispasr-windows.zip')
350
417
  await downloadFile(CRISPASR_WIN_URL, zip)
351
418
  await run('powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath ${JSON.stringify(zip)} -DestinationPath ${JSON.stringify(paths.winDir)} -Force`], { timeout: 300000 })
352
- const found = []
353
- for (const name of fs.readdirSync(paths.winDir)) {
354
- const full = path.join(paths.winDir, name)
355
- if (/crispasr(\.exe)?$/i.test(name)) found.push(full)
356
- if (dirExists(full)) {
357
- for (const sub of fs.readdirSync(full)) {
358
- if (/^crispasr(\.exe)?$/i.test(sub)) found.push(path.join(full, sub))
359
- }
360
- }
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}`)
361
426
  }
362
- if (!found[0]) throw new Error('解壓縮後找不到 crispasr.exe')
363
- if (found[0] !== paths.winCrisp) fs.copyFileSync(found[0], paths.winCrisp)
427
+ state.ggufBinary = exe
364
428
  return 'gguf'
365
429
  }
366
430
 
@@ -420,6 +484,13 @@ export function createTts({ dir, logger, getConfig }) {
420
484
  setSetup('test', '測試語音合成(instruct 模式會用 GGUF 引擎)')
421
485
  await synthesize('主人,語音功能測試成功。', { env, skipConsent: true })
422
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 }
423
494
  setSetup('done', `環境已就緒(${finalProvider === 'mlx' ? 'macOS MLX' : 'CosyVoice GGUF'})`)
424
495
  return { started: true, provider: finalProvider }
425
496
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",