dsh-sound-atelier 1.0.0 → 1.0.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 +10 -0
- package/COMMUNICATION-COMPATIBILITY.md +2 -0
- package/README.en.md +2 -0
- package/README.md +3 -1
- package/SPEECH-INTEGRATION.md +26 -1
- package/lib/client-src/33-speech-delivery.js +122 -0
- package/lib/client-src/60-settings-page.js +6 -7
- package/lib/client.js +129 -7
- package/lib/index.js +16 -0
- package/lib/speech-bridge/bridge.js +69 -0
- package/lib/speech-bridge/catalog.js +24 -0
- package/lib/speech-bridge/diagnostics.js +48 -0
- package/lib/speech-bridge/index.js +129 -0
- package/lib/speech-bridge/local-request.js +14 -0
- package/lib/speech-bridge/voice-provider.js +43 -0
- package/lib/updater.js +70 -41
- package/package.json +1 -1
- package/test/speech-bridge-bridge.test.mjs +75 -0
- package/test/speech-bridge-builtin.test.mjs +113 -0
- package/test/speech-bridge-diagnostics.test.mjs +26 -0
- package/test/speech-bridge-local-request.test.mjs +21 -0
- package/test/updater-cli.test.mjs +68 -0
- package/tools/check-release.mjs +14 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
# 1.0.2 — 2026-11-15
|
|
2
|
+
|
|
3
|
+
- Voice delivery for messaging plugins is now built in (previously the separate `dsh-speech-bridge` plugin, same maintainer): the `send_speech_reply` tool for OneBot, QQBot and dsh-im conversations, the "語音交付" settings page with channel capabilities and recent results, and the optional `external:dsh-voice` speech-to-text source. Nothing extra to install.
|
|
4
|
+
- Two new settings, both on by default: `speechBridgeEnabled` and `speechBridgeExternalVoice`. The tool only appears in conversations that have a matching messaging tool.
|
|
5
|
+
- If the old standalone bridge is still installed it keeps handling delivery and the built-in one stays out of its way.
|
|
6
|
+
|
|
7
|
+
# 1.0.1 — 2026-11-15
|
|
8
|
+
|
|
9
|
+
- Fix one-click update being unavailable: the updater now finds the DSH command-line entry in DSH Desktop and in `dsh web`, runs it the way the host was started, and reads the profile from where the plugin is installed instead of assuming `web`.
|
|
10
|
+
|
|
1
11
|
# 1.0.0 — 2026-11-15
|
|
2
12
|
|
|
3
13
|
- Prepare the renamed DSH-sound-atelier fork for source distribution; maintainer puffdog-git.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# 外部通訊插件相容性檢查
|
|
2
2
|
|
|
3
|
+
> **1.0.2 更新**:下文提到的獨立 `dsh-speech-bridge` 已併入聲音工坊,不必另外安裝。用法與開關見 [SPEECH-INTEGRATION.md](SPEECH-INTEGRATION.md) 的「通訊語音交付」。以下保留併入前的檢查紀錄。
|
|
4
|
+
|
|
3
5
|
## 後續實作已加入
|
|
4
6
|
|
|
5
7
|
使用者後續要求原生語音泡泡並以模擬驗收:已新增本機 `dsh-im-voice` 5.10.0-voice.1 分支,透過語音意圖協商接入 10 個通道的原生語音路徑,詳見該分支 NATIVE-VOICE.md。Slack/Email/iMessage 維持音訊附件。原版 dsh-im 備份保留,TTS 本身未納入通訊協定。以下「dsh-im 只附件」敘述適用原版與不支援的新通道,不再代表本機語音分支的完整能力。
|
package/README.en.md
CHANGED
|
@@ -32,6 +32,7 @@ Based on [dsh-tts](https://github.com/GooDAnDReaDY/dsh-tts) 0.4.42 by GooDAnDRea
|
|
|
32
32
|
- 🇯🇵 **Chinese/Japanese dubbing.** Chinese on screen, Japanese out loud. Great for role-play.
|
|
33
33
|
- 🧬 **Voice cloning.** Build a voice from a recording; RVC voice conversion is supported too.
|
|
34
34
|
- 🛟 **Switches when one breaks.** If the first speech service fails, the next in your list takes over.
|
|
35
|
+
- 💬 **Voice messages in chat apps.** With the QQ (OneBot, QQBot) or dsh-im messaging plugins, ask for a spoken reply and the AI sends it as a voice message.
|
|
35
36
|
|
|
36
37
|
## 💞 Made for Discord-cyberfriend
|
|
37
38
|
|
|
@@ -90,6 +91,7 @@ Does the user have a source folder instead of a market install? Follow "Installi
|
|
|
90
91
|
- **Cloud speech services cost money.** Reading, previews and creating voices may all be billed, to your own account. Edge is free.
|
|
91
92
|
- **Local models are yours to set up.** None are bundled, large ones need an NVIDIA GPU, and the one-click setup is tested on Windows only.
|
|
92
93
|
- **Not every service was tested with a paid account.** Try a short phrase when switching to a new one.
|
|
94
|
+
- **Voice messages in chat apps are simulation-tested only.** Real delivery on QQ and other platforms has not been verified by a person.
|
|
93
95
|
- **Get consent before cloning someone's voice.** Models have their own licences too, and some forbid commercial use.
|
|
94
96
|
- **Do not install the upstream `@goodandready/dsh-tts` alongside this.** The two conflict.
|
|
95
97
|
|
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
- 🇯🇵 **中日配音**:畫面顯示中文,嘴巴講日文,角色扮演很對味。
|
|
33
33
|
- 🧬 **複製聲音**:用一段錄音建立專屬音色;也支援 RVC 變聲。
|
|
34
34
|
- 🛟 **壞了會自動換**:第一個語音服務失敗時,依你排好的順序換下一個。
|
|
35
|
+
- 💬 **在聊天軟體送語音訊息**:搭配 QQ(OneBot、QQBot)或 dsh-im 通訊插件時,你說一聲,AI 就把回覆合成語音、當成語音訊息送出。
|
|
35
36
|
|
|
36
37
|
## 💞 和 Discord 賽博群友是天生一對
|
|
37
38
|
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
|
|
46
47
|
## 🚀 安裝
|
|
47
48
|
|
|
48
|
-
在 DSH 的**插件市集**搜尋 `dsh-sound-atelier
|
|
49
|
+
在 DSH 的**插件市集**搜尋 `dsh-sound-atelier`,按安裝就好(正在提交審核)。
|
|
49
50
|
|
|
50
51
|
喜歡用指令的話:
|
|
51
52
|
|
|
@@ -90,6 +91,7 @@ dsh plugin --profile web add dsh-sound-atelier
|
|
|
90
91
|
- **雲端語音服務會收費。** 朗讀、試聽、建立音色都可能計費,帳單在你自己的帳號上。Edge 是免費的。
|
|
91
92
|
- **本機模型要自己準備。** 插件不附模型,大型模型需要 NVIDIA 顯示卡;一鍵安裝只在 Windows 上測試過。
|
|
92
93
|
- **不是每一家都實際付費測過。** 換新的語音服務時,先用一句短句試聽。
|
|
94
|
+
- **聊天軟體的語音訊息只做過模擬測試。** QQ 和其他平台的實際送達還沒有真人驗證過。
|
|
93
95
|
- **複製別人的聲音前請取得同意。** 模型本身也各有授權,有些不能商用。
|
|
94
96
|
- **請不要同時安裝上游的 `@goodandready/dsh-tts`。** 兩個會打架。
|
|
95
97
|
|
package/SPEECH-INTEGRATION.md
CHANGED
|
@@ -4,12 +4,37 @@
|
|
|
4
4
|
|
|
5
5
|
## 設定頁
|
|
6
6
|
|
|
7
|
-
目前主入口為「設定 →
|
|
7
|
+
目前主入口為「設定 → 語音」,分成「朗讀與聲音」和「語音交付」。語音交付從 1.0.2 起內建(原本是獨立的 dsh-speech-bridge),不必另外安裝;交付頁有兩個開關,見下方「通訊語音交付」。人格頁的「在聊天軟體中使用語音」提供一般使用者操作說明,下列保留給插件作者的接口契約。
|
|
8
8
|
|
|
9
9
|
全繁中介面分為「語音來源」「聲音管理」「人格與語言」「進階設定」。先選線上語音服務或本機部署,再選主要服務、模型、聲音與試聽。分類切換只更換主要服務列,備援列仍可混用線上與本機;設定需按儲存。服務的模型 ID、音色 ID、API 協議名稱、第三方回傳內容與診斷 JSON 保留原文。
|
|
10
10
|
|
|
11
11
|
聲音管理保留錄音、八家雲端永久音色、本機推理服務、模型/索引/音色包、RVC、背景工作及診斷。安裝與載入需明確操作,開啟頁面不會下載模型或建立付費音色。
|
|
12
12
|
|
|
13
|
+
## 通訊語音交付
|
|
14
|
+
|
|
15
|
+
1.0.2 起內建,程式在 `lib/speech-bridge/`。不儲存通訊帳號憑證,也不修改其他插件。
|
|
16
|
+
|
|
17
|
+
在 OneBot、QQBot 或 dsh-im 的正式對話裡,Agent 可以呼叫 `send_speech_reply({text, channel:"auto", instruction})`。這個工具只在目前對話有對應的通訊工具時才會出現,所以沒有安裝這些通訊插件的人不會看到它。它是「使用者要求語音回覆時」用的工具,不會把所有文字回覆自動變成語音。
|
|
18
|
+
|
|
19
|
+
| 出口 | 使用的原插件工具 | 交付形式 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| dsh-onebot-qq | `qq_send_voice` | 原生語音,受原插件的成員權限與路徑限制 |
|
|
22
|
+
| @zaofan/dsh-qqbot | `send_media(kind=voice)` | 原生語音,收件人由目前對話決定 |
|
|
23
|
+
| @xmanrui/dsh-im | `dsh_im_return_file` | 支援時要求原生語音泡泡,否則是音訊附件;登記成功只代表已排程,不代表平台已送達 |
|
|
24
|
+
|
|
25
|
+
音訊由聲音工坊自己合成,沿用人格/工作區/對話的音色設定與語氣提示。MP3 上限 4 MiB、文字上限 10000 字;超過會拒絕,不會截斷。一個對話一次只跑一個交付工作,可以取消,不會自動重送。
|
|
26
|
+
|
|
27
|
+
設定頁「語音交付」分頁的兩個開關:
|
|
28
|
+
|
|
29
|
+
- **讓 AI 用語音訊息回覆**(`speechBridgeEnabled`,預設開):關掉後工具立刻消失。
|
|
30
|
+
- **提供 dsh-voice 作為語音辨識來源**(`speechBridgeExternalVoice`,預設開):把外部 `@goodandready/dsh-voice` 的聽寫功能登記成來源 `external:dsh-voice`。需要另外安裝並設定該插件;不會自動切換目前使用的語音辨識;變更後要重新啟動 DSH。
|
|
31
|
+
|
|
32
|
+
狀態端點 `GET /dsh-sound-atelier/speech-bridge/status`(只接受本機連線)回傳通道能力與本次啟動最近 30 筆交付狀態,不含文字、錄音、收件人或私人路徑。
|
|
33
|
+
|
|
34
|
+
如果電腦上還裝著舊的獨立 `dsh-speech-bridge`,聲音工坊會讓它繼續負責、不重複註冊工具;兩者功能相同,可以把舊的移除。
|
|
35
|
+
|
|
36
|
+
這部分只做過模擬測試(真的通訊插件工具、最後的網路傳輸用替身),沒有在真實的 QQ 或其他平台驗證送達。
|
|
37
|
+
|
|
13
38
|
## 人格與音色方案
|
|
14
39
|
|
|
15
40
|
先在語音來源調整設定,再在「人格與語言」將目前服務鏈、音色、語氣、本機參考、RVC 及配音語言存成具名方案。方案只保存安全憑證槽名稱,不保存 API Key。選擇人格、工作區或會話後套用綁定,表格會列出使用的方案及主要聲音。方案與綁定立即儲存,與全域設定的儲存按鈕分開;刪除方案只解除本機綁定,不刪除雲端聲音、錄音或權重。
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// 「語音交付」頁:通道能力、格式限制與最近交付結果,以及兩個開關。
|
|
2
|
+
//
|
|
3
|
+
// 面板原本在獨立插件 dsh-speech-bridge 裡,1.0.2 起併入聲音工坊。
|
|
4
|
+
// 電腦上還裝著舊的獨立橋接時,60-settings-page.js 會優先用它註冊的面板。
|
|
5
|
+
|
|
6
|
+
// 語音交付的兩個開關:讀取與儲存都走聲音工坊自己的設定接口。
|
|
7
|
+
function SpeechDeliverySettings({ status, onChanged }) {
|
|
8
|
+
const h = React.createElement
|
|
9
|
+
const [config, setConfig] = React.useState(null)
|
|
10
|
+
const [busy, setBusy] = React.useState(false)
|
|
11
|
+
const [error, setError] = React.useState('')
|
|
12
|
+
const alive = React.useRef(true)
|
|
13
|
+
const read = async () => {
|
|
14
|
+
const response = await clientFetch('/dsh-sound-atelier/config', { cache: 'no-store', timeoutMs: 8000 })
|
|
15
|
+
const data = await response.json()
|
|
16
|
+
if (!response.ok || !data.ok) throw Error('無法讀取設定')
|
|
17
|
+
return data
|
|
18
|
+
}
|
|
19
|
+
React.useEffect(() => {
|
|
20
|
+
alive.current = true
|
|
21
|
+
read()
|
|
22
|
+
.then((data) => alive.current && setConfig(data.config))
|
|
23
|
+
.catch((failure) => alive.current && setError(String(failure?.message || failure)))
|
|
24
|
+
return () => {
|
|
25
|
+
alive.current = false
|
|
26
|
+
}
|
|
27
|
+
}, [])
|
|
28
|
+
const toggle = async (key) => {
|
|
29
|
+
if (busy) return
|
|
30
|
+
setBusy(true)
|
|
31
|
+
setError('')
|
|
32
|
+
try {
|
|
33
|
+
const current = await read()
|
|
34
|
+
const response = await clientFetch('/dsh-sound-atelier/config', {
|
|
35
|
+
method: 'PUT',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ ...current.config, [key]: current.config[key] === false, revision: current.revision }),
|
|
38
|
+
timeoutMs: 10000,
|
|
39
|
+
})
|
|
40
|
+
const saved = await response.json().catch(() => ({}))
|
|
41
|
+
if (!response.ok || !saved.ok) throw Error(saved?.error?.message || '儲存失敗(HTTP ' + response.status + ')')
|
|
42
|
+
if (alive.current) setConfig(saved.config)
|
|
43
|
+
onChanged?.()
|
|
44
|
+
} catch (failure) {
|
|
45
|
+
if (alive.current) setError(String(failure?.message || failure))
|
|
46
|
+
} finally {
|
|
47
|
+
if (alive.current) setBusy(false)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const row = (key, title, help) =>
|
|
51
|
+
h(
|
|
52
|
+
'label',
|
|
53
|
+
{ className: 'sv-switch', key },
|
|
54
|
+
h('input', {
|
|
55
|
+
type: 'checkbox',
|
|
56
|
+
'data-speech-bridge-setting': key,
|
|
57
|
+
checked: config ? config[key] !== false : true,
|
|
58
|
+
disabled: !config || busy,
|
|
59
|
+
onChange: () => toggle(key),
|
|
60
|
+
}),
|
|
61
|
+
h('span', null, h('strong', null, title), h('small', { className: 'sv-muted' }, help)),
|
|
62
|
+
)
|
|
63
|
+
const sttPending = config && status && (config.speechBridgeExternalVoice !== false) !== (status.externalVoiceEnabled !== false)
|
|
64
|
+
return h(
|
|
65
|
+
'div',
|
|
66
|
+
{ className: 'sv-settings' },
|
|
67
|
+
row(
|
|
68
|
+
'speechBridgeEnabled',
|
|
69
|
+
'讓 AI 用語音訊息回覆',
|
|
70
|
+
'在 QQ(OneBot、QQBot)或 dsh-im 的對話裡,你要求語音回覆時,AI 會合成語音並用原本的通訊插件送出。沒有安裝這些通訊插件時不會有任何作用。',
|
|
71
|
+
),
|
|
72
|
+
row(
|
|
73
|
+
'speechBridgeExternalVoice',
|
|
74
|
+
'提供 dsh-voice 作為語音辨識來源',
|
|
75
|
+
'需要另外安裝並設定 dsh-voice。開啟只是多一個可選的來源,不會自動切換目前使用的語音辨識。變更後要重新啟動 DSH 才生效。',
|
|
76
|
+
),
|
|
77
|
+
sttPending ? h('p', { className: 'sv-muted', role: 'status' }, '語音辨識來源的變更已儲存,重新啟動 DSH 後生效。') : null,
|
|
78
|
+
status?.standalone
|
|
79
|
+
? h(
|
|
80
|
+
'p',
|
|
81
|
+
{ className: 'sv-muted', role: 'status' },
|
|
82
|
+
'偵測到另外安裝的 dsh-speech-bridge,目前由它提供語音交付。聲音工坊已內建相同功能,可以把它移除。',
|
|
83
|
+
)
|
|
84
|
+
: null,
|
|
85
|
+
error ? h('p', { className: 'sv-error', role: 'alert' }, error) : null,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const SpeechDeliveryPanel = (() => {
|
|
90
|
+
const h=React.createElement
|
|
91
|
+
const labels={generating:'合成中',queued:'已排程',sent:'已送出',attachment:'附件已送出',failed:'交付失敗',unknown:'結果不明',cancelled:'已取消'}
|
|
92
|
+
const css=`.sv-settings{display:grid;gap:10px;margin:0 0 18px;padding:14px 16px;border:1px solid var(--sv-line);border-radius:14px}.sv-switch{display:flex;gap:10px;align-items:flex-start;cursor:pointer}.sv-switch input{margin-top:5px;flex-shrink:0}.sv-switch span{display:flex;flex-direction:column;gap:2px;min-width:0}.sv-switch small{font-size:12px}.sv-panel{--sv-accent:var(--dsw-alias-brand-primary,#4d6bfe);--sv-line:var(--dsw-alias-border-l2,#8b9c9a35);color:var(--dsw-alias-label-primary,#203b37);max-width:1120px;padding:20px 4px 40px;font:14px/1.6 system-ui;overflow-wrap:anywhere}.sv-panel *{box-sizing:border-box}.sv-panel h2,.sv-panel h3,.sv-panel p{margin:0}.sv-panel button,.sv-panel input,.sv-panel select{font:inherit;color:inherit}.sv-panel button{cursor:pointer}.sv-top{display:flex;justify-content:space-between;align-items:start;gap:16px;margin-bottom:22px}.sv-top h2{font-size:25px;letter-spacing:.04em}.sv-muted{opacity:.7;font-size:13px}.sv-btn{background:transparent;border:1px solid var(--sv-line);border-radius:10px;padding:8px 15px;white-space:nowrap}.sv-btn:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 7%,transparent)}.sv-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:18px 0}.sv-metric{border:1px solid var(--sv-line);border-radius:14px;padding:16px;background:linear-gradient(120deg,color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 5%,transparent),transparent)}.sv-metric strong{font-size:27px;display:block;line-height:1.3}.sv-note{border-left:3px solid var(--sv-accent);padding:12px 15px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 4%,transparent);border-radius:0 10px 10px 0;margin:16px 0}.sv-badge{display:inline-flex;font-size:12px;border-radius:20px;padding:3px 10px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 9%,transparent);color:var(--sv-accent);white-space:nowrap}.sv-badge.warn{color:#9c6419;background:#c18c2520}.sv-badge.bad{color:#b74e48;background:#b74e4818}.sv-tabs{display:flex;gap:8px;border-bottom:1px solid var(--sv-line);margin:24px 0 16px}.sv-tabs button{border:0;background:transparent;padding:10px 16px;border-bottom:3px solid transparent}.sv-tabs button[aria-current=page]{color:var(--sv-accent);border-bottom-color:var(--sv-accent);font-weight:700}.sv-filters{display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-bottom:16px}.sv-filters label{display:flex;flex-direction:column;gap:5px;font-size:12px}.sv-filters input,.sv-filters select{background:transparent;border:1px solid var(--sv-line);border-radius:9px;padding:8px 10px;max-width:100%;min-width:0}.sv-filters label:first-child{flex:1;min-width:170px}.sv-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(285px,100%),1fr));gap:12px}.sv-card{border:1px solid var(--sv-line);border-radius:14px;padding:17px;background:var(--dsw-alias-bg-layer-3,transparent)}.sv-card-head{display:flex;justify-content:space-between;gap:8px;align-items:start}.sv-card h3{font-size:16px}.sv-spec{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 0 12px}.sv-spec span{display:block;font-size:11px;opacity:.65}.sv-spec strong{font-size:14px}.sv-card details{font-size:12px;margin:12px 0}.sv-card summary{cursor:pointer;color:var(--sv-accent)}.sv-card footer{border-top:1px solid var(--sv-line);padding-top:10px;font-size:12px;opacity:.7}.sv-results{display:grid;gap:10px}.sv-result{border:1px solid var(--sv-line);border-radius:12px;padding:16px}.sv-result p{margin-top:8px}.sv-empty{text-align:center;border:1px dashed var(--sv-line);border-radius:14px;padding:36px 18px}.sv-error{padding:14px;border:1px solid #b74e4870;border-radius:12px;color:#b74e48}.sv-panel :focus-visible{outline:3px solid var(--dsw-alias-brand-primary,#4d6bfe);outline-offset:3px}@media(max-width:500px){.sv-panel{padding:12px 0 24px}.sv-metrics{gap:7px}.sv-metric{padding:12px 9px}.sv-metric strong{font-size:23px}.sv-metric span{font-size:11px}.sv-top h2{font-size:22px}.sv-filters label{flex:1;min-width:100px}.sv-tabs button{padding:10px}.sv-card{padding:15px}}@media(prefers-color-scheme:dark){.sv-panel{--sv-accent:var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary,#e2eee9)}.sv-panel option{background:#182923;color:#e2eee9}}`
|
|
93
|
+
function Panel({active=true}){
|
|
94
|
+
const [data,setData]=React.useState(null),[error,setError]=React.useState(''),[tab,setTab]=React.useState('channels'),[filter,setFilter]=React.useState('all'),[search,setSearch]=React.useState(''),[onlyFailures,setOnlyFailures]=React.useState(false),[updated,setUpdated]=React.useState('')
|
|
95
|
+
const current=React.useRef(null),alive=React.useRef(true)
|
|
96
|
+
const load=React.useCallback(async()=>{
|
|
97
|
+
if(current.current)return
|
|
98
|
+
const controller=new AbortController();current.current=controller;const timer=setTimeout(()=>controller.abort(),10000)
|
|
99
|
+
try{const r=await fetch('/dsh-sound-atelier/speech-bridge/status',{credentials:'same-origin',signal:controller.signal,cache:'no-store'});if(!r.ok)throw Error();const out=await r.json();if(!out.ok||!Array.isArray(out.catalog))throw Error();if(!alive.current)return;setData(out);setError('');setUpdated(new Date().toLocaleTimeString('zh-TW',{hour:'2-digit',minute:'2-digit',second:'2-digit'}))}catch{if(alive.current)setError(controller.signal.aborted?'讀取狀態逾時,請稍後按「更新」重試。':'無法讀取交付狀態,請稍後按「更新」重試。')}finally{clearTimeout(timer);if(current.current===controller)current.current=null}
|
|
100
|
+
},[])
|
|
101
|
+
React.useEffect(()=>{if(!active)return;alive.current=true;load();const timer=setInterval(()=>{if(!document.hidden)load()},5000);return()=>{alive.current=false;clearInterval(timer);current.current?.abort()}},[load,active])
|
|
102
|
+
const catalog=data?.catalog||[],recent=data?.recent||[],failed=recent.filter(r=>['failed','unknown'].includes(r.state)),shown=catalog.filter(c=>(filter==='all'||filter==='native'&&c.native||filter==='attachment'&&!c.native)&&`${c.name} ${c.source} ${c.format}`.toLowerCase().includes(search.toLowerCase())),results=onlyFailures?failed:recent
|
|
103
|
+
const nameFor=id=>catalog.find(c=>c.channel===id)?.name||({auto:'自動選擇',im:'dsh-im',onebot:'OneBot',qqbot:'QQBot'})[id]||'其他通道'
|
|
104
|
+
const badge=(text,kind)=>h('span',{className:'sv-badge '+(kind||'')},text)
|
|
105
|
+
return h('section',{className:'sv-panel','aria-label':'語音交付狀態'},h('style',null,css),
|
|
106
|
+
h('header',{className:'sv-top'},h('div',null,h('h2',null,'語音交付'),h('p',{className:'sv-muted'},'查看通道格式、限制與最近交付結果。')),h('button',{type:'button',className:'sv-btn',onClick:load},'更新')),
|
|
107
|
+
error?h('p',{className:'sv-error',role:'alert'},error):null,
|
|
108
|
+
h(SpeechDeliverySettings,{status:data,onChanged:load}),
|
|
109
|
+
!data&&!error?h('p',{role:'status'},'正在讀取通道能力…'):null,
|
|
110
|
+
data?h(React.Fragment,null,
|
|
111
|
+
h('div',{className:'sv-card-head'},badge(!data.deliveryEnabled?'語音交付已關閉':data.deliveryReady||data.standalone?'語音交付已就緒':'語音交付尚未就緒',data.deliveryEnabled&&(data.deliveryReady||data.standalone)?'':'warn'),h('span',{className:'sv-muted'},'最後更新 ',updated)),
|
|
112
|
+
h('div',{className:'sv-metrics'},[[catalog.filter(c=>c.native).length,'原生語音介面'],[catalog.filter(c=>!c.native).length,'附件交付通道'],[failed.length,'失敗或結果不明']].map(([n,label])=>h('div',{className:'sv-metric',key:label},h('strong',null,n),h('span',null,label)))),
|
|
113
|
+
h('div',{className:'sv-note'},'已排程 ≠ 已送達。原生語音只在支援的通道使用;超過上限會拒絕,不會截斷句尾。',h('p',{className:'sv-muted'},data.imLoaded?'dsh-im 語音擴充已載入;帳號連線由原通訊插件確認。':'dsh-im 語音擴充尚未載入;下列顯示已實作的能力,啟用原插件後才能交付。')),
|
|
114
|
+
h('nav',{className:'sv-tabs','aria-label':'語音交付頁籤'},[['channels','通道能力'],['recent','最近交付']].map(([id,title])=>h('button',{type:'button',key:id,'aria-current':tab===id?'page':undefined,onClick:()=>setTab(id)},title))),
|
|
115
|
+
tab==='channels'?h(React.Fragment,null,
|
|
116
|
+
h('div',{className:'sv-filters'},h('label',null,'搜尋通道',h('input',{type:'search',value:search,placeholder:'例如:微信、Discord','aria-label':'搜尋通道',onChange:e=>setSearch(e.target.value)})),h('label',null,'交付類型',h('select',{value:filter,'aria-label':'交付類型',onChange:e=>setFilter(e.target.value)},h('option',{value:'all'},'全部通道'),h('option',{value:'native'},'原生語音'),h('option',{value:'attachment'},'音訊附件')))),
|
|
117
|
+
h('div',{className:'sv-grid'},shown.length?shown.map(c=>h('article',{className:'sv-card',key:c.id},h('div',{className:'sv-card-head'},h('div',null,h('h3',null,c.name),h('p',{className:'sv-muted'},c.source)),badge(c.native?'原生語音':'音訊附件',c.native?'':'warn')),h('div',{className:'sv-spec'},h('div',null,h('span',null,'單則時長'),h('strong',null,c.maxSeconds?c.maxSeconds+' 秒':c.native?'依平台限制':'依附件限制')),h('div',null,h('span',null,'橋接輸入上限'),h('strong',null,'4 MiB'))),h('p',null,c.format),h('details',null,h('summary',null,'格式與限制說明'),h('p',null,c.limitNote),h('p',null,c.note)),h('footer',null,c.availability==='not-loaded'?'語音擴充未載入':c.availability==='loaded'?'擴充已載入 · 連線由原插件確認':'由目前通訊會話確認可用性'))):h('p',{className:'sv-empty'},'沒有符合條件的通道。'))
|
|
118
|
+
):h(React.Fragment,null,h('div',{className:'sv-filters'},h('label',{style:{display:'flex',flexDirection:'row',alignItems:'center'}},h('input',{type:'checkbox',checked:onlyFailures,onChange:e=>setOnlyFailures(e.target.checked)}),'僅顯示失敗或結果不明')),h('p',{className:'sv-muted'},'只保留本次啟動最近 30 筆狀態,不保存文字、錄音、收件人或私人路徑。'),h('div',{className:'sv-results'},results.length?results.map(r=>h('article',{className:'sv-result',key:r.id},h('div',{className:'sv-card-head'},h('strong',null,nameFor(r.channel)),badge(labels[r.state]||'待確認',['failed','unknown'].includes(r.state)?'bad':r.state==='queued'?'warn':'')),h('small',{className:'sv-muted'},new Date(r.at).toLocaleString('zh-TW')),r.reason?h(React.Fragment,null,h('p',null,h('strong',null,r.reason.title)),h('p',null,r.reason.action)):r.state==='queued'?h('p',null,'音訊已交給本回合流程,等待通訊插件的送達結果。'):null)):h('div',{className:'sv-empty'},h('strong',null,onlyFailures?'目前沒有失敗紀錄':'還沒有語音交付紀錄'),h('p',{className:'sv-muted'},'使用通訊會話的語音回覆工具後,狀態會出現在這裡。'))))
|
|
119
|
+
):null)
|
|
120
|
+
}
|
|
121
|
+
return Panel
|
|
122
|
+
})()
|
|
@@ -98,13 +98,12 @@ function SpeechSettingsPage(props) {
|
|
|
98
98
|
hidden: tab !== 'delivery',
|
|
99
99
|
},
|
|
100
100
|
React.createElement(ErrorBoundary, null, React.createElement(DiscordLinkCard, { active: tab === 'delivery' })),
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
),
|
|
101
|
+
// 語音交付已內建;電腦上另外裝了舊的獨立橋接時,沿用它註冊的面板(狀態在它那邊)。
|
|
102
|
+
React.createElement(
|
|
103
|
+
ErrorBoundary,
|
|
104
|
+
null,
|
|
105
|
+
Delivery ? React.createElement(Delivery) : React.createElement(SpeechDeliveryPanel, { active: tab === 'delivery' }),
|
|
106
|
+
),
|
|
108
107
|
),
|
|
109
108
|
)
|
|
110
109
|
}
|
package/lib/client.js
CHANGED
|
@@ -6809,6 +6809,129 @@ function DiscordLinkCard({ active }) {
|
|
|
6809
6809
|
body,
|
|
6810
6810
|
)
|
|
6811
6811
|
}
|
|
6812
|
+
// ---- 33-speech-delivery.js
|
|
6813
|
+
// 「語音交付」頁:通道能力、格式限制與最近交付結果,以及兩個開關。
|
|
6814
|
+
//
|
|
6815
|
+
// 面板原本在獨立插件 dsh-speech-bridge 裡,1.0.2 起併入聲音工坊。
|
|
6816
|
+
// 電腦上還裝著舊的獨立橋接時,60-settings-page.js 會優先用它註冊的面板。
|
|
6817
|
+
|
|
6818
|
+
// 語音交付的兩個開關:讀取與儲存都走聲音工坊自己的設定接口。
|
|
6819
|
+
function SpeechDeliverySettings({ status, onChanged }) {
|
|
6820
|
+
const h = React.createElement
|
|
6821
|
+
const [config, setConfig] = React.useState(null)
|
|
6822
|
+
const [busy, setBusy] = React.useState(false)
|
|
6823
|
+
const [error, setError] = React.useState('')
|
|
6824
|
+
const alive = React.useRef(true)
|
|
6825
|
+
const read = async () => {
|
|
6826
|
+
const response = await clientFetch('/dsh-sound-atelier/config', { cache: 'no-store', timeoutMs: 8000 })
|
|
6827
|
+
const data = await response.json()
|
|
6828
|
+
if (!response.ok || !data.ok) throw Error('無法讀取設定')
|
|
6829
|
+
return data
|
|
6830
|
+
}
|
|
6831
|
+
React.useEffect(() => {
|
|
6832
|
+
alive.current = true
|
|
6833
|
+
read()
|
|
6834
|
+
.then((data) => alive.current && setConfig(data.config))
|
|
6835
|
+
.catch((failure) => alive.current && setError(String(failure?.message || failure)))
|
|
6836
|
+
return () => {
|
|
6837
|
+
alive.current = false
|
|
6838
|
+
}
|
|
6839
|
+
}, [])
|
|
6840
|
+
const toggle = async (key) => {
|
|
6841
|
+
if (busy) return
|
|
6842
|
+
setBusy(true)
|
|
6843
|
+
setError('')
|
|
6844
|
+
try {
|
|
6845
|
+
const current = await read()
|
|
6846
|
+
const response = await clientFetch('/dsh-sound-atelier/config', {
|
|
6847
|
+
method: 'PUT',
|
|
6848
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6849
|
+
body: JSON.stringify({ ...current.config, [key]: current.config[key] === false, revision: current.revision }),
|
|
6850
|
+
timeoutMs: 10000,
|
|
6851
|
+
})
|
|
6852
|
+
const saved = await response.json().catch(() => ({}))
|
|
6853
|
+
if (!response.ok || !saved.ok) throw Error(saved?.error?.message || '儲存失敗(HTTP ' + response.status + ')')
|
|
6854
|
+
if (alive.current) setConfig(saved.config)
|
|
6855
|
+
onChanged?.()
|
|
6856
|
+
} catch (failure) {
|
|
6857
|
+
if (alive.current) setError(String(failure?.message || failure))
|
|
6858
|
+
} finally {
|
|
6859
|
+
if (alive.current) setBusy(false)
|
|
6860
|
+
}
|
|
6861
|
+
}
|
|
6862
|
+
const row = (key, title, help) =>
|
|
6863
|
+
h(
|
|
6864
|
+
'label',
|
|
6865
|
+
{ className: 'sv-switch', key },
|
|
6866
|
+
h('input', {
|
|
6867
|
+
type: 'checkbox',
|
|
6868
|
+
'data-speech-bridge-setting': key,
|
|
6869
|
+
checked: config ? config[key] !== false : true,
|
|
6870
|
+
disabled: !config || busy,
|
|
6871
|
+
onChange: () => toggle(key),
|
|
6872
|
+
}),
|
|
6873
|
+
h('span', null, h('strong', null, title), h('small', { className: 'sv-muted' }, help)),
|
|
6874
|
+
)
|
|
6875
|
+
const sttPending = config && status && (config.speechBridgeExternalVoice !== false) !== (status.externalVoiceEnabled !== false)
|
|
6876
|
+
return h(
|
|
6877
|
+
'div',
|
|
6878
|
+
{ className: 'sv-settings' },
|
|
6879
|
+
row(
|
|
6880
|
+
'speechBridgeEnabled',
|
|
6881
|
+
'讓 AI 用語音訊息回覆',
|
|
6882
|
+
'在 QQ(OneBot、QQBot)或 dsh-im 的對話裡,你要求語音回覆時,AI 會合成語音並用原本的通訊插件送出。沒有安裝這些通訊插件時不會有任何作用。',
|
|
6883
|
+
),
|
|
6884
|
+
row(
|
|
6885
|
+
'speechBridgeExternalVoice',
|
|
6886
|
+
'提供 dsh-voice 作為語音辨識來源',
|
|
6887
|
+
'需要另外安裝並設定 dsh-voice。開啟只是多一個可選的來源,不會自動切換目前使用的語音辨識。變更後要重新啟動 DSH 才生效。',
|
|
6888
|
+
),
|
|
6889
|
+
sttPending ? h('p', { className: 'sv-muted', role: 'status' }, '語音辨識來源的變更已儲存,重新啟動 DSH 後生效。') : null,
|
|
6890
|
+
status?.standalone
|
|
6891
|
+
? h(
|
|
6892
|
+
'p',
|
|
6893
|
+
{ className: 'sv-muted', role: 'status' },
|
|
6894
|
+
'偵測到另外安裝的 dsh-speech-bridge,目前由它提供語音交付。聲音工坊已內建相同功能,可以把它移除。',
|
|
6895
|
+
)
|
|
6896
|
+
: null,
|
|
6897
|
+
error ? h('p', { className: 'sv-error', role: 'alert' }, error) : null,
|
|
6898
|
+
)
|
|
6899
|
+
}
|
|
6900
|
+
|
|
6901
|
+
const SpeechDeliveryPanel = (() => {
|
|
6902
|
+
const h=React.createElement
|
|
6903
|
+
const labels={generating:'合成中',queued:'已排程',sent:'已送出',attachment:'附件已送出',failed:'交付失敗',unknown:'結果不明',cancelled:'已取消'}
|
|
6904
|
+
const css=`.sv-settings{display:grid;gap:10px;margin:0 0 18px;padding:14px 16px;border:1px solid var(--sv-line);border-radius:14px}.sv-switch{display:flex;gap:10px;align-items:flex-start;cursor:pointer}.sv-switch input{margin-top:5px;flex-shrink:0}.sv-switch span{display:flex;flex-direction:column;gap:2px;min-width:0}.sv-switch small{font-size:12px}.sv-panel{--sv-accent:var(--dsw-alias-brand-primary,#4d6bfe);--sv-line:var(--dsw-alias-border-l2,#8b9c9a35);color:var(--dsw-alias-label-primary,#203b37);max-width:1120px;padding:20px 4px 40px;font:14px/1.6 system-ui;overflow-wrap:anywhere}.sv-panel *{box-sizing:border-box}.sv-panel h2,.sv-panel h3,.sv-panel p{margin:0}.sv-panel button,.sv-panel input,.sv-panel select{font:inherit;color:inherit}.sv-panel button{cursor:pointer}.sv-top{display:flex;justify-content:space-between;align-items:start;gap:16px;margin-bottom:22px}.sv-top h2{font-size:25px;letter-spacing:.04em}.sv-muted{opacity:.7;font-size:13px}.sv-btn{background:transparent;border:1px solid var(--sv-line);border-radius:10px;padding:8px 15px;white-space:nowrap}.sv-btn:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 7%,transparent)}.sv-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:18px 0}.sv-metric{border:1px solid var(--sv-line);border-radius:14px;padding:16px;background:linear-gradient(120deg,color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 5%,transparent),transparent)}.sv-metric strong{font-size:27px;display:block;line-height:1.3}.sv-note{border-left:3px solid var(--sv-accent);padding:12px 15px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 4%,transparent);border-radius:0 10px 10px 0;margin:16px 0}.sv-badge{display:inline-flex;font-size:12px;border-radius:20px;padding:3px 10px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 9%,transparent);color:var(--sv-accent);white-space:nowrap}.sv-badge.warn{color:#9c6419;background:#c18c2520}.sv-badge.bad{color:#b74e48;background:#b74e4818}.sv-tabs{display:flex;gap:8px;border-bottom:1px solid var(--sv-line);margin:24px 0 16px}.sv-tabs button{border:0;background:transparent;padding:10px 16px;border-bottom:3px solid transparent}.sv-tabs button[aria-current=page]{color:var(--sv-accent);border-bottom-color:var(--sv-accent);font-weight:700}.sv-filters{display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-bottom:16px}.sv-filters label{display:flex;flex-direction:column;gap:5px;font-size:12px}.sv-filters input,.sv-filters select{background:transparent;border:1px solid var(--sv-line);border-radius:9px;padding:8px 10px;max-width:100%;min-width:0}.sv-filters label:first-child{flex:1;min-width:170px}.sv-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(285px,100%),1fr));gap:12px}.sv-card{border:1px solid var(--sv-line);border-radius:14px;padding:17px;background:var(--dsw-alias-bg-layer-3,transparent)}.sv-card-head{display:flex;justify-content:space-between;gap:8px;align-items:start}.sv-card h3{font-size:16px}.sv-spec{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 0 12px}.sv-spec span{display:block;font-size:11px;opacity:.65}.sv-spec strong{font-size:14px}.sv-card details{font-size:12px;margin:12px 0}.sv-card summary{cursor:pointer;color:var(--sv-accent)}.sv-card footer{border-top:1px solid var(--sv-line);padding-top:10px;font-size:12px;opacity:.7}.sv-results{display:grid;gap:10px}.sv-result{border:1px solid var(--sv-line);border-radius:12px;padding:16px}.sv-result p{margin-top:8px}.sv-empty{text-align:center;border:1px dashed var(--sv-line);border-radius:14px;padding:36px 18px}.sv-error{padding:14px;border:1px solid #b74e4870;border-radius:12px;color:#b74e48}.sv-panel :focus-visible{outline:3px solid var(--dsw-alias-brand-primary,#4d6bfe);outline-offset:3px}@media(max-width:500px){.sv-panel{padding:12px 0 24px}.sv-metrics{gap:7px}.sv-metric{padding:12px 9px}.sv-metric strong{font-size:23px}.sv-metric span{font-size:11px}.sv-top h2{font-size:22px}.sv-filters label{flex:1;min-width:100px}.sv-tabs button{padding:10px}.sv-card{padding:15px}}@media(prefers-color-scheme:dark){.sv-panel{--sv-accent:var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary,#e2eee9)}.sv-panel option{background:#182923;color:#e2eee9}}`
|
|
6905
|
+
function Panel({active=true}){
|
|
6906
|
+
const [data,setData]=React.useState(null),[error,setError]=React.useState(''),[tab,setTab]=React.useState('channels'),[filter,setFilter]=React.useState('all'),[search,setSearch]=React.useState(''),[onlyFailures,setOnlyFailures]=React.useState(false),[updated,setUpdated]=React.useState('')
|
|
6907
|
+
const current=React.useRef(null),alive=React.useRef(true)
|
|
6908
|
+
const load=React.useCallback(async()=>{
|
|
6909
|
+
if(current.current)return
|
|
6910
|
+
const controller=new AbortController();current.current=controller;const timer=setTimeout(()=>controller.abort(),10000)
|
|
6911
|
+
try{const r=await fetch('/dsh-sound-atelier/speech-bridge/status',{credentials:'same-origin',signal:controller.signal,cache:'no-store'});if(!r.ok)throw Error();const out=await r.json();if(!out.ok||!Array.isArray(out.catalog))throw Error();if(!alive.current)return;setData(out);setError('');setUpdated(new Date().toLocaleTimeString('zh-TW',{hour:'2-digit',minute:'2-digit',second:'2-digit'}))}catch{if(alive.current)setError(controller.signal.aborted?'讀取狀態逾時,請稍後按「更新」重試。':'無法讀取交付狀態,請稍後按「更新」重試。')}finally{clearTimeout(timer);if(current.current===controller)current.current=null}
|
|
6912
|
+
},[])
|
|
6913
|
+
React.useEffect(()=>{if(!active)return;alive.current=true;load();const timer=setInterval(()=>{if(!document.hidden)load()},5000);return()=>{alive.current=false;clearInterval(timer);current.current?.abort()}},[load,active])
|
|
6914
|
+
const catalog=data?.catalog||[],recent=data?.recent||[],failed=recent.filter(r=>['failed','unknown'].includes(r.state)),shown=catalog.filter(c=>(filter==='all'||filter==='native'&&c.native||filter==='attachment'&&!c.native)&&`${c.name} ${c.source} ${c.format}`.toLowerCase().includes(search.toLowerCase())),results=onlyFailures?failed:recent
|
|
6915
|
+
const nameFor=id=>catalog.find(c=>c.channel===id)?.name||({auto:'自動選擇',im:'dsh-im',onebot:'OneBot',qqbot:'QQBot'})[id]||'其他通道'
|
|
6916
|
+
const badge=(text,kind)=>h('span',{className:'sv-badge '+(kind||'')},text)
|
|
6917
|
+
return h('section',{className:'sv-panel','aria-label':'語音交付狀態'},h('style',null,css),
|
|
6918
|
+
h('header',{className:'sv-top'},h('div',null,h('h2',null,'語音交付'),h('p',{className:'sv-muted'},'查看通道格式、限制與最近交付結果。')),h('button',{type:'button',className:'sv-btn',onClick:load},'更新')),
|
|
6919
|
+
error?h('p',{className:'sv-error',role:'alert'},error):null,
|
|
6920
|
+
h(SpeechDeliverySettings,{status:data,onChanged:load}),
|
|
6921
|
+
!data&&!error?h('p',{role:'status'},'正在讀取通道能力…'):null,
|
|
6922
|
+
data?h(React.Fragment,null,
|
|
6923
|
+
h('div',{className:'sv-card-head'},badge(!data.deliveryEnabled?'語音交付已關閉':data.deliveryReady||data.standalone?'語音交付已就緒':'語音交付尚未就緒',data.deliveryEnabled&&(data.deliveryReady||data.standalone)?'':'warn'),h('span',{className:'sv-muted'},'最後更新 ',updated)),
|
|
6924
|
+
h('div',{className:'sv-metrics'},[[catalog.filter(c=>c.native).length,'原生語音介面'],[catalog.filter(c=>!c.native).length,'附件交付通道'],[failed.length,'失敗或結果不明']].map(([n,label])=>h('div',{className:'sv-metric',key:label},h('strong',null,n),h('span',null,label)))),
|
|
6925
|
+
h('div',{className:'sv-note'},'已排程 ≠ 已送達。原生語音只在支援的通道使用;超過上限會拒絕,不會截斷句尾。',h('p',{className:'sv-muted'},data.imLoaded?'dsh-im 語音擴充已載入;帳號連線由原通訊插件確認。':'dsh-im 語音擴充尚未載入;下列顯示已實作的能力,啟用原插件後才能交付。')),
|
|
6926
|
+
h('nav',{className:'sv-tabs','aria-label':'語音交付頁籤'},[['channels','通道能力'],['recent','最近交付']].map(([id,title])=>h('button',{type:'button',key:id,'aria-current':tab===id?'page':undefined,onClick:()=>setTab(id)},title))),
|
|
6927
|
+
tab==='channels'?h(React.Fragment,null,
|
|
6928
|
+
h('div',{className:'sv-filters'},h('label',null,'搜尋通道',h('input',{type:'search',value:search,placeholder:'例如:微信、Discord','aria-label':'搜尋通道',onChange:e=>setSearch(e.target.value)})),h('label',null,'交付類型',h('select',{value:filter,'aria-label':'交付類型',onChange:e=>setFilter(e.target.value)},h('option',{value:'all'},'全部通道'),h('option',{value:'native'},'原生語音'),h('option',{value:'attachment'},'音訊附件')))),
|
|
6929
|
+
h('div',{className:'sv-grid'},shown.length?shown.map(c=>h('article',{className:'sv-card',key:c.id},h('div',{className:'sv-card-head'},h('div',null,h('h3',null,c.name),h('p',{className:'sv-muted'},c.source)),badge(c.native?'原生語音':'音訊附件',c.native?'':'warn')),h('div',{className:'sv-spec'},h('div',null,h('span',null,'單則時長'),h('strong',null,c.maxSeconds?c.maxSeconds+' 秒':c.native?'依平台限制':'依附件限制')),h('div',null,h('span',null,'橋接輸入上限'),h('strong',null,'4 MiB'))),h('p',null,c.format),h('details',null,h('summary',null,'格式與限制說明'),h('p',null,c.limitNote),h('p',null,c.note)),h('footer',null,c.availability==='not-loaded'?'語音擴充未載入':c.availability==='loaded'?'擴充已載入 · 連線由原插件確認':'由目前通訊會話確認可用性'))):h('p',{className:'sv-empty'},'沒有符合條件的通道。'))
|
|
6930
|
+
):h(React.Fragment,null,h('div',{className:'sv-filters'},h('label',{style:{display:'flex',flexDirection:'row',alignItems:'center'}},h('input',{type:'checkbox',checked:onlyFailures,onChange:e=>setOnlyFailures(e.target.checked)}),'僅顯示失敗或結果不明')),h('p',{className:'sv-muted'},'只保留本次啟動最近 30 筆狀態,不保存文字、錄音、收件人或私人路徑。'),h('div',{className:'sv-results'},results.length?results.map(r=>h('article',{className:'sv-result',key:r.id},h('div',{className:'sv-card-head'},h('strong',null,nameFor(r.channel)),badge(labels[r.state]||'待確認',['failed','unknown'].includes(r.state)?'bad':r.state==='queued'?'warn':'')),h('small',{className:'sv-muted'},new Date(r.at).toLocaleString('zh-TW')),r.reason?h(React.Fragment,null,h('p',null,h('strong',null,r.reason.title)),h('p',null,r.reason.action)):r.state==='queued'?h('p',null,'音訊已交給本回合流程,等待通訊插件的送達結果。'):null)):h('div',{className:'sv-empty'},h('strong',null,onlyFailures?'目前沒有失敗紀錄':'還沒有語音交付紀錄'),h('p',{className:'sv-muted'},'使用通訊會話的語音回覆工具後,狀態會出現在這裡。'))))
|
|
6931
|
+
):null)
|
|
6932
|
+
}
|
|
6933
|
+
return Panel
|
|
6934
|
+
})()
|
|
6812
6935
|
// ---- 40-tts-section.js
|
|
6813
6936
|
// 模組層級的翻譯函式:內容不依賴元件狀態,放在元件外才不會每次重繪都換一個新函式
|
|
6814
6937
|
//(子元件以它作為 effect 依賴,例如更新檢查)。
|
|
@@ -8948,13 +9071,12 @@ function SpeechSettingsPage(props) {
|
|
|
8948
9071
|
hidden: tab !== 'delivery',
|
|
8949
9072
|
},
|
|
8950
9073
|
React.createElement(ErrorBoundary, null, React.createElement(DiscordLinkCard, { active: tab === 'delivery' })),
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
),
|
|
9074
|
+
// 語音交付已內建;電腦上另外裝了舊的獨立橋接時,沿用它註冊的面板(狀態在它那邊)。
|
|
9075
|
+
React.createElement(
|
|
9076
|
+
ErrorBoundary,
|
|
9077
|
+
null,
|
|
9078
|
+
Delivery ? React.createElement(Delivery) : React.createElement(SpeechDeliveryPanel, { active: tab === 'delivery' }),
|
|
9079
|
+
),
|
|
8958
9080
|
),
|
|
8959
9081
|
)
|
|
8960
9082
|
}
|
package/lib/index.js
CHANGED
|
@@ -42,6 +42,7 @@ import { referenceFingerprint } from './providers/cosyvoice.js'
|
|
|
42
42
|
import { fileFingerprint } from './providers/reference-audio.js'
|
|
43
43
|
import { trimReplayWindow } from './replay-window.js'
|
|
44
44
|
import { ensureFfmpeg } from './ffmpeg-ensure.js'
|
|
45
|
+
import { applySpeechBridge } from './speech-bridge/index.js'
|
|
45
46
|
import {
|
|
46
47
|
CLOUD_PROVIDERS,
|
|
47
48
|
assertCredentialRef,
|
|
@@ -342,6 +343,10 @@ export const Config = z.object({
|
|
|
342
343
|
timeoutMs: z.number().step(1).min(1000).volatile().default(60000),
|
|
343
344
|
maxQueue: z.number().step(1).min(0).volatile().default(8)
|
|
344
345
|
.description('Synthesis queue budget (up to four parallel requests). Zero disables automatic speech. Completed audio has a separate replay window.'),
|
|
346
|
+
speechBridgeEnabled: z.boolean().volatile().default(true)
|
|
347
|
+
.description('讓 AI 在 OneBot/QQBot/dsh-im 的對話裡可以用語音訊息回覆(send_speech_reply 工具)。沒有安裝這些通訊插件時不會有任何作用。'),
|
|
348
|
+
speechBridgeExternalVoice: z.boolean().volatile().default(true)
|
|
349
|
+
.description('把外部 dsh-voice 的聽寫功能登記成一個語音辨識來源(external:dsh-voice)。不會自動切換目前使用的語音辨識;變更後重新啟動 DSH 才生效。'),
|
|
345
350
|
})
|
|
346
351
|
|
|
347
352
|
const LEGACY_NAMES = ['dsh-sound-atelier', 'goodandready/dsh-tts', 'dsh-tts', '@goodandready/dsh-tts']
|
|
@@ -1089,4 +1094,15 @@ export function apply(ctx, config) {
|
|
|
1089
1094
|
},
|
|
1090
1095
|
}),
|
|
1091
1096
|
)
|
|
1097
|
+
|
|
1098
|
+
// 通訊語音交付(原 dsh-speech-bridge):失敗時只影響語音交付,不影響朗讀。
|
|
1099
|
+
try {
|
|
1100
|
+
applySpeechBridge(ctx, {
|
|
1101
|
+
tts: speechService,
|
|
1102
|
+
enabled: () => live().speechBridgeEnabled !== false,
|
|
1103
|
+
externalVoice: live().speechBridgeExternalVoice !== false,
|
|
1104
|
+
})
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
console.warn('[dsh-sound-atelier] 語音交付橋接沒有啟動:' + String(error?.message || error))
|
|
1107
|
+
}
|
|
1092
1108
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {randomUUID} from 'node:crypto'
|
|
4
|
+
import {failureReason} from './diagnostics.js'
|
|
5
|
+
|
|
6
|
+
export const CHANNELS={onebot:'qq_send_voice',qqbot:'send_media',im:'dsh_im_return_file'}
|
|
7
|
+
export function chooseChannel(tools,agent,requested='auto') {
|
|
8
|
+
let available=Object.entries(CHANNELS).filter(([,name])=>tools.executable(name,agent))
|
|
9
|
+
if(requested!=='auto'){
|
|
10
|
+
if(!available.some(([id])=>id===requested))throw Error('目前會話沒有此通訊插件的語音交付工具')
|
|
11
|
+
return requested
|
|
12
|
+
}
|
|
13
|
+
// dsh-im exposes its artifact tool globally; a scoped native channel wins.
|
|
14
|
+
if(available.some(([id])=>id!=='im'))available=available.filter(([id])=>id!=='im')
|
|
15
|
+
if(available.length!==1)throw Error(available.length?'目前會話有多個通訊出口,請指定 channel':'請在 OneBot、QQBot 或 dsh-im 的正式會話使用此工具')
|
|
16
|
+
return available[0][0]
|
|
17
|
+
}
|
|
18
|
+
export async function dispatch(tools,name,args,exec){
|
|
19
|
+
exec.signal.throwIfAborted()
|
|
20
|
+
const batch=await tools.execute({agent:exec.agent,signal:exec.signal,calls:[{type:'tool-call',id:randomUUID(),name,arguments:JSON.stringify(args)}]})
|
|
21
|
+
const result=batch.results?.[0]?.result
|
|
22
|
+
if(!result||result.isError)throw Object.assign(Error(result?.error?.message||'交付工具未完成;不會自動重送'),{code:result?.error?.code||result?.error?.info?.code})
|
|
23
|
+
const value=result.value
|
|
24
|
+
if(value?.ok===false||value?.sent===false)throw Error(value.msg||value.error||'通訊插件拒絕交付')
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
27
|
+
export function createBridge({tools,tts,enabled=()=>true,onEvent=()=>{}}){
|
|
28
|
+
const pending=new Map(),controllers=new Set();let closed=false
|
|
29
|
+
const emit=event=>{try{onEvent(event)}catch{}}
|
|
30
|
+
async function run(args,exec,id){
|
|
31
|
+
if(closed||!enabled())throw Error('通訊語音橋接已停用')
|
|
32
|
+
if(!exec?.agent?.session||!exec.signal)throw Error('需要真實 Agent 會話')
|
|
33
|
+
const text=String(args.text||'').trim();if(!text||text.length>10000)throw Error('配音文字須為 1–10000 字')
|
|
34
|
+
if(args.instruction!==undefined&&(typeof args.instruction!=='string'||args.instruction.length>4000))throw Error('語氣提示上限 4000 字')
|
|
35
|
+
const channel=chooseChannel(tools,exec.agent,args.channel||'auto'),name=CHANNELS[channel]
|
|
36
|
+
const sid=exec.agent.session.id||exec.agent.session.header?.id
|
|
37
|
+
if(!sid)throw Error('會話缺少識別碼')
|
|
38
|
+
if(pending.has(sid))throw Error('此會話已有語音交付工作,請勿重複送出')
|
|
39
|
+
const controller=new AbortController(),signal=AbortSignal.any([exec.signal,controller.signal,AbortSignal.timeout(180000)])
|
|
40
|
+
pending.set(sid,controller);controllers.add(controller)
|
|
41
|
+
let artifact,dir
|
|
42
|
+
try{
|
|
43
|
+
signal.throwIfAborted()
|
|
44
|
+
emit({id,channel,state:'generating',stage:'synthesis'})
|
|
45
|
+
try{artifact=await tts.exportAudio({text,format:'mp3',sessionId:sid,signal,speechInstruction:args.instruction})}catch(error){error.safeStage='synthesis';throw error}
|
|
46
|
+
if(artifact.size>4*1024*1024)throw Error('音訊超過橋接 4 MiB 上限,請縮短文字')
|
|
47
|
+
let file=artifact.path
|
|
48
|
+
if(channel==='onebot'){
|
|
49
|
+
const cwd=exec.agent.session.header?.cwd
|
|
50
|
+
if(!cwd||!path.isAbsolute(cwd))throw Error('OneBot 會話沒有可用的工作區;不會改用其他目錄')
|
|
51
|
+
const root=await fs.realpath(cwd)
|
|
52
|
+
dir=await fs.mkdtemp(path.join(root,'.dsh-speech-'))
|
|
53
|
+
file=path.join(dir,'reply.mp3');await fs.copyFile(artifact.path,file)
|
|
54
|
+
}
|
|
55
|
+
signal.throwIfAborted()
|
|
56
|
+
if(closed||!enabled()||tools.executable(name,exec.agent)===undefined)throw Error('交付前插件或權限已變更')
|
|
57
|
+
const voiceCapable=channel==='im'&&!!tools.executable(name,exec.agent)?.parameters?.properties?.presentation
|
|
58
|
+
const payload=channel==='qqbot'?{kind:'voice',source:file}:{path:file,...(voiceCapable?{presentation:'voice'}:{})}
|
|
59
|
+
const receipt=await dispatch(tools,name,payload,{...exec,signal})
|
|
60
|
+
emit({id,artifactId:receipt?.artifactId,channel,state:channel==='im'?'queued':'sent',stage:'delivery'})
|
|
61
|
+
return {ok:true,channel,state:channel==='im'?'staged':'sent',nativeVoice:channel!=='im',deliveryMode:voiceCapable?'native-when-supported':channel==='im'?'audio-attachment':'native-voice',receipt:receipt??null}
|
|
62
|
+
}finally{
|
|
63
|
+
pending.delete(sid);controllers.delete(controller)
|
|
64
|
+
try{if(dir)await fs.rm(dir,{recursive:true,force:true})}finally{await artifact?.remove()}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function send(args,exec){const id=randomUUID();try{return await run(args,exec,id)}catch(error){const reason=failureReason(error,error.safeStage||'delivery');emit({id,channel:args.channel||'auto',state:reason.code==='cancelled'?'cancelled':reason.code==='unknown'?'unknown':'failed',code:reason.code,stage:error.safeStage||'delivery'});throw error}}
|
|
68
|
+
return {send,close(){closed=true;for(const c of controllers)c.abort()}}
|
|
69
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const rows=[
|
|
2
|
+
['telegram','Telegram','Ogg Opus',120,'保留主題與引用回覆'],
|
|
3
|
+
['whatsapp','WhatsApp','Ogg Opus',120,'以 PTT 語音便箋交付'],
|
|
4
|
+
['discord','Discord','Ogg Opus',120,'包含時長與波形'],
|
|
5
|
+
['matrix','Matrix','Ogg Opus',120,'語音外觀依客戶端擴充支援'],
|
|
6
|
+
['feishu','飛書','Ogg Opus',120,'保留討論串回覆'],
|
|
7
|
+
['qq','QQ','MP3',120,'由 QQ SDK 處理語音上傳'],
|
|
8
|
+
['wecom','企微機器人','AMR-NB',60,'使用官方 SDK 的語音訊息'],
|
|
9
|
+
['wecom-app','企微自建應用','AMR-NB',60,'使用應用語音訊息接口'],
|
|
10
|
+
['dingtalk','釘釘','AMR-NB',60,'使用語音模板,時長以毫秒傳遞'],
|
|
11
|
+
['weixin','微信 iLink','Tencent SILK',60,'依官方專案協定接線;尚無真平台驗收'],
|
|
12
|
+
['slack','Slack','原音訊附件',null,'目前通道使用檔案上傳,非原生語音泡泡'],
|
|
13
|
+
['email','Email','原音訊附件',null,'電子郵件附件不提供聊天語音泡泡'],
|
|
14
|
+
['imessage','iMessage','原音訊附件',null,'目前通道未提供原生語音便箋接口'],
|
|
15
|
+
]
|
|
16
|
+
export function channelCatalog({imLoaded=false,reported=[]}={}){
|
|
17
|
+
return rows.map(([id,name,format,maxSeconds,note])=>{
|
|
18
|
+
const report=reported.find(r=>r.id===id),native=maxSeconds!==null
|
|
19
|
+
return {id:'im:'+id,channel:id,name,source:'dsh-im',native,format,maxSeconds:native?(report?.maxSeconds||maxSeconds):null,maxBytes:4*1024*1024,availability:imLoaded?'loaded':'not-loaded',note,limitNote:native?'本機交付上限,超限拒絕,不截斷':'受附件大小及平台限制;不套用原生語音時長上限'}
|
|
20
|
+
}).concat([
|
|
21
|
+
{id:'onebot',channel:'onebot',name:'OneBot',source:'獨立通訊插件',native:true,format:'MP3 → OneBot record',maxSeconds:null,maxBytes:4*1024*1024,availability:'session',note:'權限與可用性由目前會話的 OneBot 工具確認',limitNote:'時長依 NapCat 等後端限制'},
|
|
22
|
+
{id:'qqbot',channel:'qqbot',name:'QQBot',source:'獨立通訊插件',native:true,format:'MP3 → QQ voice',maxSeconds:null,maxBytes:4*1024*1024,availability:'session',note:'權限與可用性由目前會話的 QQBot 工具確認',limitNote:'時長及回覆時效依 QQ 平台限制'},
|
|
23
|
+
])
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const reasons={
|
|
2
|
+
permission:['服務權限不足','請檢查通訊插件的帳號權限、可發送範圍與媒體權限。'],
|
|
3
|
+
duration:['語音超過時長上限','請縮短回覆,或改用一般音訊附件;系統不會截斷內容。'],
|
|
4
|
+
size:['音訊超過大小上限','請縮短內容或降低音訊大小;橋接輸入上限為 4 MiB。'],
|
|
5
|
+
conversion:['音訊轉碼失敗','請確認音訊可讀取及轉碼引擎可用,再重新產生音訊。'],
|
|
6
|
+
unavailable:['目前會話沒有可用出口','請啟用對應通訊插件,並在它的正式會話使用語音交付。'],
|
|
7
|
+
context:['本回合沒有可用的交付範圍','請在原通訊會話重試;不要複製其他會話的目的地。'],
|
|
8
|
+
unknown:['平台送達結果不明','請先確認平台是否已收到;系統不會自動重送或補送附件。'],
|
|
9
|
+
rate:['服務暫時限制發送','請稍後再試,並檢查原通訊服務的發送頻率限制。'],
|
|
10
|
+
synthesis:['語音合成失敗','請到 TTS 設定檢查目前服務、金鑰權限與可用音色。'],
|
|
11
|
+
failed:['語音交付失敗','請檢查原通訊插件的狀態與紀錄;此處不顯示私人路徑或金鑰。'],
|
|
12
|
+
cancelled:['語音工作已取消','工作已停止;若平台已開始傳輸,請先確認是否收到,避免重送。'],
|
|
13
|
+
}
|
|
14
|
+
export function failureReason(error={},stage='delivery'){
|
|
15
|
+
const code=String(error.code||''),message=String(error.message||'')
|
|
16
|
+
let key=error.name==='AbortError'||/cancel|abort/i.test(code)?'cancelled'
|
|
17
|
+
:/uncertain|unknown/i.test(code)?'unknown'
|
|
18
|
+
:/permission|forbidden|401|403/i.test(code)||/權限|权限|permission|forbidden|HTTP\s*[(:]?\s*(401|403)/i.test(message)?'permission'
|
|
19
|
+
:/duration/i.test(code)||/時長|上限 \d+ 秒/.test(message)?'duration'
|
|
20
|
+
:/too-large|size-limit|^size$/i.test(code)||/MiB|大小上限|檔案過大/.test(message)?'size'
|
|
21
|
+
:/conversion|ffmpeg|codec/i.test(code)||/轉碼|格式轉換/.test(message)?'conversion'
|
|
22
|
+
:/context|consumer/i.test(code)||/active IM delivery consumer|live Harness Session|交付範圍/i.test(message)?'context'
|
|
23
|
+
:/unavailable|unknown-tool/i.test(code)||/沒有.*工具|正式會話|已停用|多個通訊出口|未啟用|未連線|通道未就緒|通道未就绪|not connected/i.test(message)?'unavailable'
|
|
24
|
+
:/rate|429/i.test(code)||/rate.limit|429|限流|頻率限制/i.test(message)?'rate'
|
|
25
|
+
:stage==='synthesis'?'synthesis':'failed'
|
|
26
|
+
return {code:key,title:reasons[key][0],action:key==='permission'&&stage==='synthesis'?'請到 TTS 供應商設定檢查 API Key 的語音合成權限與模型存取權。':reasons[key][1]}
|
|
27
|
+
}
|
|
28
|
+
const states=new Set(['generating','queued','sent','attachment','failed','unknown','cancelled'])
|
|
29
|
+
const channels=new Set(['auto','onebot','qqbot','im','telegram','whatsapp','discord','matrix','feishu','qq','wecom','wecom-app','dingtalk','weixin','slack','email','imessage'])
|
|
30
|
+
export function createDiagnostics({max=30,now=Date.now}={}){
|
|
31
|
+
let rows=[];const references=new Map()
|
|
32
|
+
return {
|
|
33
|
+
record(event){
|
|
34
|
+
let channel=channels.has(event.channel)?event.channel:'auto'
|
|
35
|
+
const id=references.get(event.artifactId)||event.id||event.artifactId
|
|
36
|
+
if(typeof id!=='string'||!/^[\w-]{1,80}$/.test(id))return
|
|
37
|
+
if(event.artifactId&&/^[\w-]{1,80}$/.test(event.artifactId))references.set(event.artifactId,id)
|
|
38
|
+
const old=rows.find(r=>r.id===id),state=states.has(event.state)?event.state:'failed'
|
|
39
|
+
if(channel==='auto'&&old)channel=old.channel
|
|
40
|
+
const reason=['failed','unknown','cancelled'].includes(state)?failureReason({code:event.code||state,name:state==='cancelled'?'AbortError':''},event.stage):null
|
|
41
|
+
const row={id,at:now(),channel,state,stage:event.stage==='synthesis'?'synthesis':'delivery',reason:reason||null}
|
|
42
|
+
if(old)rows=rows.map(r=>r===old?row:r);else rows.unshift(row)
|
|
43
|
+
rows=rows.slice(0,max)
|
|
44
|
+
if(references.size>max*2){const live=new Set(rows.map(r=>r.id));for(const[k,v]of references)if(!live.has(v))references.delete(k)}
|
|
45
|
+
},
|
|
46
|
+
snapshot:()=>rows.map(r=>({...r,reason:r.reason?{...r.reason}:null})),
|
|
47
|
+
}
|
|
48
|
+
}
|