dsh-voice-say 0.5.0 → 0.6.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/README.md +41 -3
- package/client.js +50 -8
- package/index.js +40 -2
- package/lib/director.mjs +56 -5
- package/lib/dshsession.mjs +290 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -96,6 +96,36 @@ voice_direct { text: "你不要再这样了,我真的会难过的。", styles:
|
|
|
96
96
|
→ 情绪 哽咽压抑
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
## 导演台开在 DeepSeek 窗口里(0.6.0)
|
|
100
|
+
|
|
101
|
+
导演不必自己拿 key 直连模型。默认走 **DSH 里的专用会话**:插件会在 DeepSeek 窗口里建一个标题叫
|
|
102
|
+
`读音导演 · dsh-voice-say` 的会话,之后每次「这句怎么念」都在那个窗口里问 —— 你能在 GUI 里看见它想了什么、
|
|
103
|
+
为什么这么断句,也能随时进去插一句改方向。
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
设置页点「试算标点」
|
|
107
|
+
→ 插件找到 DSH(桥接里记着的 baseUrl)→ 用本机会话密钥签一个 Cookie
|
|
108
|
+
→ session/prompt:把「规则 + 人设 + 七情 + 最近聊天 + 这句台词」发进那个会话
|
|
109
|
+
→ session/page 轮询,等这一轮说完 → 拿回 JSON(render / notes / emotion)
|
|
110
|
+
→ 过四道保底(逐字核对 → 重试 → 盖章法 → 退回原文)→ 交给引擎念
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
为什么这样更省心:
|
|
114
|
+
|
|
115
|
+
- **不碰 key**:用宿主自己的 provider / 模型 / 凭据,插件里没有任何密钥。
|
|
116
|
+
- **看得见**:导演的每次判断都留在那个会话里,是对话历史也是日志。
|
|
117
|
+
- **不重复开窗**:会话 id 存在 `config.json` 的 `director.sessionId`,只有会话没了才新建。
|
|
118
|
+
- **有退路**:DSH 连不上(或你把 `director.transport` 设成 `api`)就退回直连模型 API,合成照常出声。
|
|
119
|
+
|
|
120
|
+
设置页那张卡里能看到:通道、导演窗口(会话 id)、DSH 地址与来源、会话是否就绪;右上角 **「换一个导演窗口」**
|
|
121
|
+
会清掉会话 id,下一次合成自动在 DeepSeek 里新开一个。
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
GET /api/voice-say/direct/session → 看现在开着哪个窗口
|
|
125
|
+
POST /api/voice-say/direct/session {reset:true} → 清掉,下次新建
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
|
|
99
129
|
## QQ 里的声音 = 设置页试听(0.5.0)
|
|
100
130
|
|
|
101
131
|
以前群里发出去的语音和这里试听**不是一个嗓子**,三个原因叠在一起:
|
|
@@ -185,10 +215,16 @@ dsh plugin --profile <profile> add dsh-voice-say
|
|
|
185
215
|
| `cloudGuidanceField` | `guidance_scale` | 有的服务叫 `cfg_scale` |
|
|
186
216
|
| `timeoutMs` | `600000` | 单次合成超时 |
|
|
187
217
|
| `director.enabled` | `true` | 合成前要不要先过读音导演 |
|
|
188
|
-
| `director.
|
|
189
|
-
| `director.
|
|
218
|
+
| `director.transport` | `dsh` | `dsh` = 在 DeepSeek 窗口里的专用会话里想;`api` = 直连模型 API |
|
|
219
|
+
| `director.dshBaseUrl` | 自动 | DSH 地址;不填就用 QQ 桥接记着的 `dsh.baseUrl` |
|
|
220
|
+
| `director.sessionId` | 自动 | 那个导演窗口的 id,第一次合成时建好写进来;清空 = 下次重开 |
|
|
221
|
+
| `director.sessionTitle` | `读音导演 · dsh-voice-say` | 新开会话用的标题(一眼能在 GUI 里认出来) |
|
|
222
|
+
| `director.sessionCwd` | 工作区 | 会话的工作目录 |
|
|
223
|
+
| `director.sessionTimeoutMs` | `60000` | 在会话里等一轮说完的上限 |
|
|
224
|
+
| `director.baseUrl` / `director.model` | `https://ai.xyclub.top/v1` / `deepseek-v4.1-flash` | 走 `api` 通道时用的模型(和 QQ 同一个) |
|
|
225
|
+
| `director.apiKey` / `apiKeyEnv` | 空 / `DPSK_API_KEY` | `api` 通道的 key,优先用它,其次环境变量,其次 `~/.dsh/.credentials.yaml` |
|
|
190
226
|
| `director.scope` | 自动 | 钉住某个会话的剧情与情绪(`group:xxx` / `private:xxx`) |
|
|
191
|
-
| `director.timeoutMs` | `20000` |
|
|
227
|
+
| `director.timeoutMs` | `20000` | `api` 通道超时就退回原文照念 |
|
|
192
228
|
| `lifeHome` / `emotionHome` / `bridgeState` | 自动探测 | 人设 / 情绪 / 剧情三份数据的目录 |
|
|
193
229
|
|
|
194
230
|
## HTTP
|
|
@@ -202,6 +238,8 @@ dsh plugin --profile <profile> add dsh-voice-say
|
|
|
202
238
|
| GET | `/api/voice-say/direct/context` | 导演手里有什么料(人设 / 情绪 / 剧情),不发模型请求 |
|
|
203
239
|
| POST | `/api/voice-say/direct` | 只算标点:`{text,styles,instructions,scope}` |
|
|
204
240
|
| POST | `/api/voice-say/direct/cache` | 清掉导演的标点缓存 |
|
|
241
|
+
| GET | `/api/voice-say/direct/session` | 看导演窗口开在哪个会话、DSH 是否连得上 |
|
|
242
|
+
| POST | `/api/voice-say/direct/session` | `{reset:true}` 清掉会话 id(下次合成自动新开);`{sessionId}` 指定窗口 |
|
|
205
243
|
| POST | `/api/voice-say/config` | 改配置(写进 `config.json`) |
|
|
206
244
|
| GET | `/api/voice-say/voices` | 列音色 |
|
|
207
245
|
| POST | `/api/voice-say/voices` | 上传音色:`{name,text,description,filename,data}`(`data` 为 base64)或 `{name,text,src}` |
|
package/client.js
CHANGED
|
@@ -124,6 +124,7 @@ window.__ModuleLoader__.load({
|
|
|
124
124
|
})
|
|
125
125
|
const [styleList, setStyleList] = useState([])
|
|
126
126
|
const [directorOn, setDirectorOn] = useState(true)
|
|
127
|
+
const [directTransport, setDirectTransport] = useState('dsh')
|
|
127
128
|
const [directBusy, setDirectBusy] = useState('')
|
|
128
129
|
const [directRes, setDirectRes] = useState(null)
|
|
129
130
|
const [directCtx, setDirectCtx] = useState(null)
|
|
@@ -140,7 +141,10 @@ window.__ModuleLoader__.load({
|
|
|
140
141
|
const mergeStatus = useCallback((s) => {
|
|
141
142
|
setSt(s)
|
|
142
143
|
setVoices(s?.local?.voices || [])
|
|
143
|
-
if (s?.director)
|
|
144
|
+
if (s?.director) {
|
|
145
|
+
setDirectorOn(s.director.enabled !== false)
|
|
146
|
+
if (s.director.transport) setDirectTransport(s.director.transport)
|
|
147
|
+
}
|
|
144
148
|
setForm((f) => ({
|
|
145
149
|
...f,
|
|
146
150
|
provider: s?.provider || f.provider,
|
|
@@ -175,7 +179,7 @@ window.__ModuleLoader__.load({
|
|
|
175
179
|
try {
|
|
176
180
|
const payload = { ...form }
|
|
177
181
|
if (!payload.cloudApiKey) delete payload.cloudApiKey
|
|
178
|
-
payload.director = { enabled: directorOn }
|
|
182
|
+
payload.director = { enabled: directorOn, transport: directTransport }
|
|
179
183
|
const r = await jpost('/config', payload)
|
|
180
184
|
if (r?.status) mergeStatus(r.status)
|
|
181
185
|
setNotice('已保存,立刻生效')
|
|
@@ -303,7 +307,7 @@ window.__ModuleLoader__.load({
|
|
|
303
307
|
} catch (e) { setErr(e?.message || String(e)) } finally { setDirectBusy('') }
|
|
304
308
|
}
|
|
305
309
|
|
|
306
|
-
/**
|
|
310
|
+
/** 一次性看一眼导演手里到底有什么料(人设/情绪/聊天),不发模型请求。 */
|
|
307
311
|
const loadContext = async () => {
|
|
308
312
|
setDirectBusy('ctx'); setErr('')
|
|
309
313
|
try {
|
|
@@ -312,6 +316,16 @@ window.__ModuleLoader__.load({
|
|
|
312
316
|
} catch (e) { setErr(e?.message || String(e)) } finally { setDirectBusy('') }
|
|
313
317
|
}
|
|
314
318
|
|
|
319
|
+
/** 换一个导演窗口:清掉会话 id,下一次合成会在 DeepSeek 窗口里新建一个。 */
|
|
320
|
+
const newSession = async () => {
|
|
321
|
+
setDirectBusy('session'); setErr(''); setNotice('')
|
|
322
|
+
try {
|
|
323
|
+
await jpost('/direct/session', { reset: true })
|
|
324
|
+
await load()
|
|
325
|
+
setNotice('已清空:下一次合成会在 DeepSeek 窗口里新开一个「读音导演」会话')
|
|
326
|
+
} catch (e) { setErr(e?.message || String(e)) } finally { setDirectBusy('') }
|
|
327
|
+
}
|
|
328
|
+
|
|
315
329
|
const local = st?.local || {}
|
|
316
330
|
const cloud = st?.cloud || {}
|
|
317
331
|
const director = st?.director || {}
|
|
@@ -382,22 +396,48 @@ window.__ModuleLoader__.load({
|
|
|
382
396
|
directBusy === 'direct' ? '算标点中…' : '试算标点'),
|
|
383
397
|
h('button', { 'data-vs-btn': '', disabled: directBusy === 'ctx', onClick: loadContext },
|
|
384
398
|
directBusy === 'ctx' ? '读取中…' : '看上下文'),
|
|
399
|
+
h('button', { 'data-vs-btn': '', disabled: directBusy === 'session', onClick: newSession },
|
|
400
|
+
directBusy === 'session' ? '清空中…' : '换一个导演窗口'),
|
|
385
401
|
),
|
|
386
402
|
h('div', { 'data-vs-b': '' },
|
|
387
403
|
h('div', { 'data-vs-sub': '' },
|
|
388
404
|
'合成之前,先把台词交给「和 QQ 同一个大脑」:它看得到她的人设、此刻的七情、刚才在聊什么,'
|
|
389
405
|
+ '据此重排标点与停顿——哪句断、哪里停一口气、结尾是句号还是省略号。它只许动标点,字词改一个就作废、退回原文。'),
|
|
390
406
|
h('div', { 'data-vs-grid': '' },
|
|
407
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '通道'),
|
|
408
|
+
h('select', {
|
|
409
|
+
'data-vs-select': '', value: directTransport,
|
|
410
|
+
onChange: (e) => { setDirectTransport(e.target.value); setNotice('通道改了,记得点下方「保存」') },
|
|
411
|
+
},
|
|
412
|
+
h('option', { value: 'dsh' }, 'DeepSeek 窗口里的专用会话'),
|
|
413
|
+
h('option', { value: 'api' }, '直连模型 API(不建会话)'))),
|
|
414
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '导演窗口'),
|
|
415
|
+
directTransport === 'dsh'
|
|
416
|
+
? h('span', { 'data-vs-v': '', 'data-vs-mono': '' },
|
|
417
|
+
(director.dsh?.sessionId || '还没开(第一次合成时自动开)'))
|
|
418
|
+
: h('span', { 'data-vs-v': '' }, '不走会话')),
|
|
419
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, 'DSH 地址'),
|
|
420
|
+
h('span', { 'data-vs-v': '', 'data-vs-mono': '' },
|
|
421
|
+
(director.dsh?.baseUrl || '—') + (director.dsh?.baseFrom ? `(${director.dsh.baseFrom})` : ''))),
|
|
422
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '会话就绪'),
|
|
423
|
+
h(Badge, { ok: director.dsh?.ready }, director.dsh?.ready
|
|
424
|
+
? '能连上'
|
|
425
|
+
: (director.dsh?.baseUrl ? '连不上(密钥?)' : '不知道地址'))),
|
|
391
426
|
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '模型'),
|
|
392
|
-
h('span', { 'data-vs-v': '', 'data-vs-mono': '' }, director.model || '—')),
|
|
427
|
+
h('span', { 'data-vs-v': '', 'data-vs-mono': '' }, directTransport === 'dsh' ? '宿主当前模型' : (director.model || '—'))),
|
|
393
428
|
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, 'key'),
|
|
394
|
-
|
|
395
|
-
|
|
429
|
+
directTransport === 'dsh'
|
|
430
|
+
? h('span', { 'data-vs-v': '' }, '用宿主的凭据,插件不碰 key')
|
|
431
|
+
: h(Badge, { ok: director.hasKey }, director.hasKey ? (director.keyFrom || '已配置') : '没找到')),
|
|
432
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '看的会话'),
|
|
396
433
|
h('span', { 'data-vs-v': '' }, director.scope || '自动(最近那个)')),
|
|
397
434
|
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '超时'),
|
|
398
|
-
h('span', { 'data-vs-v': '' }, `${Math.round((director.timeoutMs || 20000) / 1000)} 秒(超时就退回原文照念)`)),
|
|
435
|
+
h('span', { 'data-vs-v': '' }, `${Math.round(((directTransport === 'dsh' ? director.dsh?.timeoutMs : director.timeoutMs) || 20000) / 1000)} 秒(超时就退回原文照念)`)),
|
|
399
436
|
),
|
|
400
|
-
director.ready ? null : h('div', { 'data-vs-err': '' },
|
|
437
|
+
director.ready ? null : h('div', { 'data-vs-err': '' },
|
|
438
|
+
directTransport === 'dsh'
|
|
439
|
+
? '导演的 DeepSeek 会话连不上(读不到 DSH 地址或会话密钥)。合成照常,只是不重排标点;也可以在下面把通道切到「直连模型 API」。'
|
|
440
|
+
: '导演缺 key:设置里填 apiKey,或让 DPSK_API_KEY 在环境里;没有 key 时合成照常,只是不重排标点。'),
|
|
401
441
|
|
|
402
442
|
directCtx
|
|
403
443
|
? h('div', { 'data-vs-say': '' },
|
|
@@ -424,7 +464,9 @@ window.__ModuleLoader__.load({
|
|
|
424
464
|
directRes.emotion ? `· 读出来的情绪:${directRes.emotion}` : '',
|
|
425
465
|
directRes.elapsedMs ? `· ${(directRes.elapsedMs / 1000).toFixed(1)} 秒` : '',
|
|
426
466
|
directRes.cached ? '· 命中缓存' : '',
|
|
467
|
+
directRes.transport === 'dsh' ? '· 在 DeepSeek 窗口里想的' : '',
|
|
427
468
|
].filter(Boolean).join(' ')),
|
|
469
|
+
directRes.sessionId ? h('div', { 'data-vs-sub': '', 'data-vs-mono': '' }, `会话:${directRes.sessionId}`) : null,
|
|
428
470
|
directRes.notes ? h('div', { 'data-vs-sub': '' }, `导演说明:${directRes.notes}`) : null,
|
|
429
471
|
directRes.reason && !directRes.applied ? h('div', { 'data-vs-err': '' }, `原因:${directRes.reason}`) : null,
|
|
430
472
|
directRes.rejected ? h('div', { 'data-vs-sub': '' }, `它想改成(已作废):${directRes.rejected}`) : null,
|
package/index.js
CHANGED
|
@@ -32,7 +32,7 @@ import { direct as directText, gatherContext, probeDirector, clearCache as clear
|
|
|
32
32
|
export const name = 'dsh-voice-say'
|
|
33
33
|
export const inject = ['webServer', 'tools']
|
|
34
34
|
|
|
35
|
-
const PLUGIN_VERSION = '0.
|
|
35
|
+
const PLUGIN_VERSION = '0.6.0'
|
|
36
36
|
const DEFAULT_INSTRUCTIONS = '说话自然一点,带点傲娇和不耐烦,别太热情。'
|
|
37
37
|
const DEFAULT_PREVIEW_TEXT = '哼,才不是特意来跟你说话的呢,笨蛋。'
|
|
38
38
|
const MAX_BODY = 32 * 1024 * 1024
|
|
@@ -124,6 +124,25 @@ export async function apply(ctx, config = {}) {
|
|
|
124
124
|
})
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 导演会话的「记下来」钩子:DSH 里那个专用会话只建一次,
|
|
129
|
+
* 建好后 sessionId 写进 config.json,之后每次都进同一个窗口。
|
|
130
|
+
*/
|
|
131
|
+
const directorHooks = {
|
|
132
|
+
async onSession(sessionId) {
|
|
133
|
+
try {
|
|
134
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}) }
|
|
135
|
+
const prev = next.director && typeof next.director === 'object' && !Array.isArray(next.director)
|
|
136
|
+
? next.director : {}
|
|
137
|
+
next.director = { ...prev, sessionId: String(sessionId || '') }
|
|
138
|
+
writeJson(uiConfigFile, next)
|
|
139
|
+
log('导演会话 →', sessionId)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
log('导演会话记不下来:', error?.message || error)
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
127
146
|
/* ───────────────────── 状态 ───────────────────── */
|
|
128
147
|
/**
|
|
129
148
|
* 自定义参考音频:上传过、文件还在,就返回它。
|
|
@@ -234,6 +253,7 @@ export async function apply(ctx, config = {}) {
|
|
|
234
253
|
styles: vs.styles,
|
|
235
254
|
instructions: vs.instructions,
|
|
236
255
|
scope: opts.scope || c.director?.scope || '',
|
|
256
|
+
hooks: directorHooks,
|
|
237
257
|
})
|
|
238
258
|
if (direction.applied) spoken = direction.text
|
|
239
259
|
}
|
|
@@ -311,6 +331,7 @@ export async function apply(ctx, config = {}) {
|
|
|
311
331
|
styles: vs.styles,
|
|
312
332
|
instructions: vs.instructions,
|
|
313
333
|
scope: opts.scope || c.director?.scope || '',
|
|
334
|
+
hooks: directorHooks,
|
|
314
335
|
})
|
|
315
336
|
if (direction.applied) spoken = direction.text
|
|
316
337
|
}
|
|
@@ -447,6 +468,7 @@ export async function apply(ctx, config = {}) {
|
|
|
447
468
|
styles: vs.styles,
|
|
448
469
|
instructions: vs.instructions,
|
|
449
470
|
scope: args?.scope || c.director?.scope || '',
|
|
471
|
+
hooks: directorHooks,
|
|
450
472
|
})
|
|
451
473
|
return JSON.parse(JSON.stringify({ ...r, styleNames: vs.styleNames }))
|
|
452
474
|
},
|
|
@@ -582,6 +604,7 @@ export async function apply(ctx, config = {}) {
|
|
|
582
604
|
styles: vs.styles,
|
|
583
605
|
instructions: vs.instructions,
|
|
584
606
|
scope: b.scope || c.director?.scope || '',
|
|
607
|
+
hooks: directorHooks,
|
|
585
608
|
})
|
|
586
609
|
return sendJson(res, 200, { ok: true, ...r, styleNames: vs.styleNames })
|
|
587
610
|
}
|
|
@@ -591,6 +614,21 @@ export async function apply(ctx, config = {}) {
|
|
|
591
614
|
return sendJson(res, 200, { ok: true, cleared: true })
|
|
592
615
|
}
|
|
593
616
|
|
|
617
|
+
// 导演会话:看它现在开在哪个窗口,或者换一个(reset=true 清掉,下次自动新建)
|
|
618
|
+
if (sub === 'direct/session') {
|
|
619
|
+
if (req.method === 'POST') {
|
|
620
|
+
const b = await readBody(req)
|
|
621
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}) }
|
|
622
|
+
const prev = next.director && typeof next.director === 'object' && !Array.isArray(next.director)
|
|
623
|
+
? next.director : {}
|
|
624
|
+
next.director = { ...prev, sessionId: b.reset ? '' : String(b.sessionId || '') }
|
|
625
|
+
writeJson(uiConfigFile, next)
|
|
626
|
+
log(b.reset ? '导演会话已清空(下次合成时新开一个窗口)' : `导演会话 → ${next.director.sessionId}`)
|
|
627
|
+
return sendJson(res, 200, { ok: true, director: probeDirector(cfg()) })
|
|
628
|
+
}
|
|
629
|
+
return sendJson(res, 200, { ok: true, director: probeDirector(cfg()) })
|
|
630
|
+
}
|
|
631
|
+
|
|
594
632
|
if (req.method === 'POST' && sub === 'config') {
|
|
595
633
|
const b = await readBody(req)
|
|
596
634
|
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}) }
|
|
@@ -751,7 +789,7 @@ export async function apply(ctx, config = {}) {
|
|
|
751
789
|
ok: false,
|
|
752
790
|
error: '没有这个接口',
|
|
753
791
|
have: ['status', 'styles', 'config', 'voices', 'voices/delete', 'voices/default', 'ref', 'synth', 'preview', 'cloud/test', 'audio',
|
|
754
|
-
'direct', 'direct/context', 'voice-plan'],
|
|
792
|
+
'direct', 'direct/context', 'direct/session', 'voice-plan'],
|
|
755
793
|
})
|
|
756
794
|
} catch (e) {
|
|
757
795
|
sendJson(res, 500, { ok: false, error: e?.message ?? String(e) })
|
package/lib/director.mjs
CHANGED
|
@@ -13,10 +13,13 @@ import fs from 'node:fs'
|
|
|
13
13
|
import os from 'node:os'
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
|
|
16
|
+
import { askSession, dshOptions, probeDsh } from './dshsession.mjs'
|
|
17
|
+
|
|
16
18
|
const DEFAULT_BASE_URL = 'https://ai.xyclub.top/v1'
|
|
17
19
|
const DEFAULT_MODEL = 'deepseek-v4.1-flash'
|
|
18
20
|
const DEFAULT_API_KEY_ENV = 'DPSK_API_KEY'
|
|
19
21
|
|
|
22
|
+
|
|
20
23
|
const LIFE_CANDIDATES = ['D:/dprz/life', 'D:/dprz', path.join(os.homedir(), '.dsh-data', 'life')]
|
|
21
24
|
const EMOTION_CANDIDATES = ['D:/dprz/qg', path.join(os.homedir(), '.dsh-data', 'emotion')]
|
|
22
25
|
const BRIDGE_CANDIDATES = [
|
|
@@ -64,8 +67,10 @@ function pickDir(explicit, envName, candidates) {
|
|
|
64
67
|
export function directorOptions(cfg = {}) {
|
|
65
68
|
const d = cfg.director && typeof cfg.director === 'object' ? cfg.director : {}
|
|
66
69
|
const num = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb)
|
|
70
|
+
const transport = String(d.transport || 'dsh').trim() === 'api' ? 'api' : 'dsh'
|
|
67
71
|
return {
|
|
68
72
|
enabled: d.enabled !== false,
|
|
73
|
+
transport,
|
|
69
74
|
baseUrl: String(d.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
70
75
|
model: String(d.model || DEFAULT_MODEL),
|
|
71
76
|
apiKey: String(d.apiKey || '').trim(),
|
|
@@ -78,6 +83,8 @@ export function directorOptions(cfg = {}) {
|
|
|
78
83
|
lifeHome: pickDir(cfg.lifeHome, 'DSH_LIFE_HOME', LIFE_CANDIDATES),
|
|
79
84
|
emotionHome: pickDir(cfg.emotionHome, 'DSH_QG_HOME', EMOTION_CANDIDATES),
|
|
80
85
|
bridgeState: pickDir(cfg.bridgeState, 'DSH_BRIDGE_STATE', BRIDGE_CANDIDATES),
|
|
86
|
+
// 走 DSH 会话时的参数(会话是「在 DeepSeek 窗口里能看见的那个导演台」)
|
|
87
|
+
dsh: dshOptions(cfg),
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
|
|
@@ -106,9 +113,13 @@ export function resolveApiKey(o) {
|
|
|
106
113
|
export function probeDirector(cfg = {}) {
|
|
107
114
|
const o = directorOptions(cfg)
|
|
108
115
|
const { key, from } = resolveApiKey(o)
|
|
116
|
+
const dsh = probeDsh(cfg)
|
|
117
|
+
const viaDsh = o.transport === 'dsh'
|
|
109
118
|
return {
|
|
110
119
|
enabled: o.enabled,
|
|
111
|
-
|
|
120
|
+
// 走会话时,只要能连上 DSH 就算就绪(不需要 key —— 用宿主自己的凭据)
|
|
121
|
+
ready: Boolean(o.enabled && (viaDsh ? dsh.ready : Boolean(key))),
|
|
122
|
+
transport: o.transport,
|
|
112
123
|
baseUrl: o.baseUrl,
|
|
113
124
|
model: o.model,
|
|
114
125
|
hasKey: Boolean(key),
|
|
@@ -119,6 +130,7 @@ export function probeDirector(cfg = {}) {
|
|
|
119
130
|
lifeHome: o.lifeHome,
|
|
120
131
|
emotionHome: o.emotionHome,
|
|
121
132
|
bridgeState: o.bridgeState,
|
|
133
|
+
dsh,
|
|
122
134
|
}
|
|
123
135
|
}
|
|
124
136
|
|
|
@@ -425,6 +437,31 @@ async function callModel(o, key, messages, extraNote) {
|
|
|
425
437
|
return { content, elapsedMs: Date.now() - started, usage: json?.usage ?? null }
|
|
426
438
|
}
|
|
427
439
|
|
|
440
|
+
/** 会话里没有独立的 system 角色,把规则与请求压成一段文字发进去。 */
|
|
441
|
+
function messagesToText(messages) {
|
|
442
|
+
return messages
|
|
443
|
+
.map((m) => (m.role === 'system' ? `【规则】\n${m.content}` : `【这一句】\n${m.content}`))
|
|
444
|
+
.join('\n\n')
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 问「同一个大脑」,两条通道:
|
|
449
|
+
* - dsh:在 DSH 里那个专用会话里问(GUI 里看得见,用宿主自己的模型与凭据);
|
|
450
|
+
* - api:直连 OpenAI 兼容接口(老路,作为会话不可用时的兜底)。
|
|
451
|
+
*/
|
|
452
|
+
async function askModel(o, key, messages, extraNote, ctx = {}) {
|
|
453
|
+
const full = extraNote ? [...messages, { role: 'user', content: extraNote }] : messages
|
|
454
|
+
if (o.transport === 'dsh') {
|
|
455
|
+
const dsh = o.dsh
|
|
456
|
+
if (!dsh.ready) throw new Error(dsh.baseUrl ? 'DSH 会话通道不可用(读不到会话密钥)' : '找不到 DSH 地址(桥接没记下 baseUrl,可在设置里填 dshBaseUrl)')
|
|
457
|
+
const r = await askSession(dsh, { text: messagesToText(full), hooks: ctx.hooks })
|
|
458
|
+
if (!r.ok) throw new Error(r.error || 'DSH 会话没回话')
|
|
459
|
+
return { content: r.text, elapsedMs: r.elapsedMs, transport: 'dsh', sessionId: r.sessionId }
|
|
460
|
+
}
|
|
461
|
+
const out = await callModel(o, key, full)
|
|
462
|
+
return { ...out, transport: 'api', sessionId: '' }
|
|
463
|
+
}
|
|
464
|
+
|
|
428
465
|
/**
|
|
429
466
|
* 真正的入口:把台词 + 人设 + 情绪 + 场景 交给同一个大脑,拿回打好标点的台词。
|
|
430
467
|
* 任何一步失败都不抛错 —— 合成不能因为「导演」挂掉就发不出声。
|
|
@@ -449,8 +486,15 @@ export async function direct(cfg = {}, input = {}) {
|
|
|
449
486
|
if (!original) { result.reason = '没有文字'; return result }
|
|
450
487
|
|
|
451
488
|
const { key, from } = resolveApiKey(o)
|
|
452
|
-
|
|
453
|
-
|
|
489
|
+
const viaDsh = o.transport === 'dsh'
|
|
490
|
+
if (viaDsh ? !o.dsh.ready : !key) {
|
|
491
|
+
result.reason = viaDsh
|
|
492
|
+
? 'DSH 会话通道没就绪(找不到 DSH 地址或会话密钥)'
|
|
493
|
+
: `没找到模型 key(可用 ${o.apiKeyEnv} 环境变量或设置里填 apiKey)`
|
|
494
|
+
return result
|
|
495
|
+
}
|
|
496
|
+
result.keyFrom = viaDsh ? 'dsh-session' : from
|
|
497
|
+
result.transport = o.transport
|
|
454
498
|
|
|
455
499
|
const identity = readIdentity(o)
|
|
456
500
|
const emotion = readEmotion(o, input.scope || o.scope)
|
|
@@ -479,21 +523,28 @@ export async function direct(cfg = {}, input = {}) {
|
|
|
479
523
|
if (cached) return { ...cached, cached: true }
|
|
480
524
|
|
|
481
525
|
try {
|
|
482
|
-
let out = await
|
|
526
|
+
let out = await askModel(o, key, messages, '', { hooks: input.hooks })
|
|
483
527
|
let parsed = parseReply(out.content)
|
|
484
528
|
result.elapsedMs = out.elapsedMs
|
|
529
|
+
if (out.transport) result.transport = out.transport
|
|
530
|
+
if (out.sessionId) {
|
|
531
|
+
result.sessionId = out.sessionId
|
|
532
|
+
o.dsh.sessionId = out.sessionId // 重试沿用同一个窗口,别又开一个
|
|
533
|
+
}
|
|
485
534
|
if (out.usage) result.usage = out.usage
|
|
486
535
|
|
|
487
536
|
if (!parsed.render || !sameWords(original, parsed.render)) {
|
|
488
537
|
// 改动字词了 —— 再给它一次机会,只说这一件事
|
|
489
|
-
out = await
|
|
538
|
+
out = await askModel(
|
|
490
539
|
o,
|
|
491
540
|
key,
|
|
492
541
|
messages,
|
|
493
542
|
'注意:你上一次的输出改动了字词,作废重做。这次只许插入或替换标点符号,汉字和数字一个都不许变,不许增删。',
|
|
543
|
+
{ hooks: input.hooks },
|
|
494
544
|
)
|
|
495
545
|
parsed = parseReply(out.content)
|
|
496
546
|
result.elapsedMs = out.elapsedMs
|
|
547
|
+
if (out.sessionId) result.sessionId = out.sessionId
|
|
497
548
|
if (out.usage) result.usage = out.usage
|
|
498
549
|
}
|
|
499
550
|
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 让「读音导演」跑在 DSH 自己的会话里 —— 而不是插件直连模型 API。
|
|
3
|
+
*
|
|
4
|
+
* 为什么这么做:用户要的是「在 DeepSeek 窗口里专门开一个会话给导演台用」。
|
|
5
|
+
* 走 DSH 会话有三个额外好处:
|
|
6
|
+
* 1. 导演的每一次思考都能在 GUI 里看见(可回溯、可干预);
|
|
7
|
+
* 2. 用的是宿主自己的 provider / 模型 / 凭据,插件不碰任何 key;
|
|
8
|
+
* 3. 会话自带上下文与压缩,插件不必自己维护历史。
|
|
9
|
+
*
|
|
10
|
+
* 实现只用到两个东西:
|
|
11
|
+
* - 本机 DSH 的浏览器会话 Cookie(用 ~/.dsh/.credentials.yaml 里的签名密钥现算,不需要启动 token);
|
|
12
|
+
* - 三个 RPC:session/create、session/prompt、session/page。
|
|
13
|
+
*
|
|
14
|
+
* 注意:这里刻意**不走 WebSocket 事件流**。session/page 一次就能读到最新一段日志,
|
|
15
|
+
* 轮询比维护一条 mux 长连接简单得多,也不怕 DSH 重启断流。
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { createHash, createHmac, randomUUID } from 'node:crypto'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 60000
|
|
23
|
+
const DEFAULT_POLL_MS = 700
|
|
24
|
+
const DEFAULT_TITLE = '读音导演 · dsh-voice-say'
|
|
25
|
+
const SESSION_TTL_MS = 3600 * 1000
|
|
26
|
+
|
|
27
|
+
const BRIDGE_CONFIG_CANDIDATES = [
|
|
28
|
+
'C:/Users/admin/Documents/deepseek-harness/default-workspace/qq-bridge-main/config.json',
|
|
29
|
+
path.join(process.cwd(), 'qq-bridge-main', 'config.json'),
|
|
30
|
+
path.join(os.homedir(), 'Documents', 'deepseek-harness', 'default-workspace', 'qq-bridge-main', 'config.json'),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const WORKSPACE_CANDIDATES = [
|
|
34
|
+
'C:/Users/admin/Documents/deepseek-harness/default-workspace',
|
|
35
|
+
path.join(os.homedir(), 'Documents', 'deepseek-harness', 'default-workspace'),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/* ───────────────────── 配置 ───────────────────── */
|
|
39
|
+
|
|
40
|
+
function readJsonSafe(file, fallback) {
|
|
41
|
+
try {
|
|
42
|
+
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
|
43
|
+
return JSON.parse(text)
|
|
44
|
+
} catch {
|
|
45
|
+
return fallback
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function firstExisting(candidates) {
|
|
50
|
+
for (const c of candidates) {
|
|
51
|
+
try {
|
|
52
|
+
if (c && fs.existsSync(c)) return c
|
|
53
|
+
} catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** DSH 的地址会随重启变化,所以顺序是:显式配置 → 环境变量 → QQ 桥接里记着的那个。 */
|
|
59
|
+
export function discoverDshBase(cfg = {}) {
|
|
60
|
+
const d = cfg.director && typeof cfg.director === 'object' ? cfg.director : {}
|
|
61
|
+
const explicit = String(d.dshBaseUrl || process.env.DSH_BASE_URL || '').trim()
|
|
62
|
+
if (explicit) return { baseUrl: explicit.replace(/\/+$/, ''), from: 'config' }
|
|
63
|
+
for (const file of BRIDGE_CONFIG_CANDIDATES) {
|
|
64
|
+
const j = readJsonSafe(file, null)
|
|
65
|
+
const url = String(j?.dsh?.baseUrl || '').trim()
|
|
66
|
+
if (url) return { baseUrl: url.replace(/\/+$/, ''), from: 'bridge-config' }
|
|
67
|
+
}
|
|
68
|
+
return { baseUrl: '', from: '' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function dshOptions(cfg = {}) {
|
|
72
|
+
const d = cfg.director && typeof cfg.director === 'object' ? cfg.director : {}
|
|
73
|
+
const num = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb)
|
|
74
|
+
const { baseUrl, from } = discoverDshBase(cfg)
|
|
75
|
+
const enabled = d.enabled !== false && d.transport !== 'api'
|
|
76
|
+
const hasSecret = Boolean(readSigningSecret())
|
|
77
|
+
return {
|
|
78
|
+
enabled,
|
|
79
|
+
// 能连上 DSH 的三个条件:通道开着、地址知道、会话密钥读得到
|
|
80
|
+
ready: Boolean(enabled && baseUrl && hasSecret),
|
|
81
|
+
hasSecret,
|
|
82
|
+
baseUrl,
|
|
83
|
+
baseFrom: from,
|
|
84
|
+
title: String(d.sessionTitle || DEFAULT_TITLE),
|
|
85
|
+
cwd: String(d.sessionCwd || '').trim() || firstExisting(WORKSPACE_CANDIDATES) || process.cwd(),
|
|
86
|
+
timeoutMs: Math.min(Math.max(num(d.sessionTimeoutMs, DEFAULT_TIMEOUT_MS), 5000), 180000),
|
|
87
|
+
pollMs: Math.min(Math.max(num(d.sessionPollMs, DEFAULT_POLL_MS), 250), 5000),
|
|
88
|
+
sessionId: String(d.sessionId || '').trim(),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ───────────────────── Cookie ───────────────────── */
|
|
93
|
+
|
|
94
|
+
function credentialsFile() {
|
|
95
|
+
const home = String(process.env.DSH_HOME || '').trim() || path.join(os.homedir(), '.dsh')
|
|
96
|
+
return path.join(home, '.credentials.yaml')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const b64url = (buf) => buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
|
100
|
+
|
|
101
|
+
/** 读 DSH 写下的浏览器会话签名密钥(32 字节)。只读,不落盘。 */
|
|
102
|
+
export function readSigningSecret() {
|
|
103
|
+
try {
|
|
104
|
+
const text = fs.readFileSync(credentialsFile(), 'utf8')
|
|
105
|
+
const m = text.match(/client-connection\/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{40,})/)
|
|
106
|
+
if (!m) return null
|
|
107
|
+
const buf = Buffer.from(m[1].replaceAll('-', '+').replaceAll('_', '/'), 'base64')
|
|
108
|
+
return buf.byteLength === 32 ? buf : null
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 为一个 DSH 地址现场签一个会话 Cookie(`name=value`)。 */
|
|
115
|
+
export function mintCookie(baseUrl, secret) {
|
|
116
|
+
const authority = new URL(String(baseUrl)).host
|
|
117
|
+
if (!authority) throw new Error('DSH 地址里没有 authority')
|
|
118
|
+
const issuedAt = Date.now()
|
|
119
|
+
const body = b64url(Buffer.from(JSON.stringify({
|
|
120
|
+
version: 1, authority, issuedAt, expiresAt: issuedAt + SESSION_TTL_MS,
|
|
121
|
+
}), 'utf8'))
|
|
122
|
+
const signature = b64url(createHmac('sha256', secret).update(body).digest())
|
|
123
|
+
const name = 'dsh-auth-' + b64url(createHash('sha256').update(authority).digest())
|
|
124
|
+
return `${name}=v1.${body}.${signature}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ───────────────────── RPC ───────────────────── */
|
|
128
|
+
|
|
129
|
+
async function rpc(o, method, request, signal) {
|
|
130
|
+
if (!o.baseUrl) throw new Error('不知道 DSH 地址(桥接没记 baseUrl?可以在设置里填 director.dshBaseUrl)')
|
|
131
|
+
const secret = readSigningSecret()
|
|
132
|
+
if (!secret) throw new Error('读不到 DSH 的浏览器会话密钥(~/.dsh/.credentials.yaml)')
|
|
133
|
+
const cookie = mintCookie(o.baseUrl, secret)
|
|
134
|
+
const res = await fetch(`${o.baseUrl}/api/${method}`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'content-type': 'application/json', cookie },
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
type: 'client-request',
|
|
139
|
+
rpcId: `voice-say-${randomUUID()}`,
|
|
140
|
+
method,
|
|
141
|
+
payload: { args: { request } },
|
|
142
|
+
}),
|
|
143
|
+
signal,
|
|
144
|
+
})
|
|
145
|
+
const json = await res.json().catch(() => null)
|
|
146
|
+
const result = json?.result
|
|
147
|
+
if (!result) throw new Error(`${method}: ${res.status} 无有效响应`)
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
const err = new Error(`${method}: ${result.error?.code || 'unknown'}: ${result.error?.message || ''}`)
|
|
150
|
+
err.code = result.error?.code
|
|
151
|
+
throw err
|
|
152
|
+
}
|
|
153
|
+
return result.value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 当前日志游标。
|
|
158
|
+
* DSH 不给「查游标」的接口,但 session/page 越界时会把真游标写在错误里
|
|
159
|
+
* (`through seq 999999999 is past cursor 19`),拿它当探针最省事。
|
|
160
|
+
*/
|
|
161
|
+
async function cursorOf(o, sessionId, signal) {
|
|
162
|
+
try {
|
|
163
|
+
await rpc(o, 'session/page', { address: { kind: 'session', sessionId }, throughSeq: 999999999, maxMessages: 1 }, signal)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const m = String(error?.message || '').match(/past cursor (\d+)/)
|
|
166
|
+
if (m) return Number(m[1])
|
|
167
|
+
throw error
|
|
168
|
+
}
|
|
169
|
+
// 极端情况:日志真的是空的,游标就是 0
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 会话还在不在。 */
|
|
174
|
+
export async function sessionAlive(o, sessionId, signal) {
|
|
175
|
+
if (!sessionId) return false
|
|
176
|
+
try {
|
|
177
|
+
await cursorOf(o, sessionId, signal)
|
|
178
|
+
return true
|
|
179
|
+
} catch {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 建会话。 */
|
|
185
|
+
export async function createSession(o, signal) {
|
|
186
|
+
const value = await rpc(o, 'session/create', { cwd: o.cwd, title: o.title }, signal)
|
|
187
|
+
const sessionId = String(value?.sessionId || '')
|
|
188
|
+
if (!sessionId) throw new Error('session/create 没返回 sessionId')
|
|
189
|
+
return sessionId
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 取一个可用会话:配置里那个还活着就复用,否则新建。 */
|
|
193
|
+
export async function ensureSession(o, signal, hooks = {}) {
|
|
194
|
+
if (o.sessionId && (await sessionAlive(o, o.sessionId, signal))) {
|
|
195
|
+
return { ok: true, sessionId: o.sessionId, created: false }
|
|
196
|
+
}
|
|
197
|
+
const sessionId = await createSession(o, signal)
|
|
198
|
+
if (typeof hooks.onSession === 'function') {
|
|
199
|
+
try { await hooks.onSession(sessionId) } catch { /* 存不下去也不影响这一次 */ }
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, sessionId, created: true }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ───────────────────── 一问一答 ───────────────────── */
|
|
205
|
+
|
|
206
|
+
function textOfAssistant(event) {
|
|
207
|
+
const content = event?.data?.message?.content
|
|
208
|
+
if (!Array.isArray(content)) return ''
|
|
209
|
+
return content
|
|
210
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
211
|
+
.map((b) => b.text)
|
|
212
|
+
.join('')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 在 DSH 会话里问一句,等它把这一轮说完。
|
|
217
|
+
* @returns {{ok:boolean, sessionId:string, text:string, elapsedMs:number, turns:number, error?:string}}
|
|
218
|
+
*/
|
|
219
|
+
export async function askSession(o, { text, sessionId, signal, hooks = {} } = {}) {
|
|
220
|
+
const started = Date.now()
|
|
221
|
+
const deadline = started + o.timeoutMs
|
|
222
|
+
let sid = sessionId || ''
|
|
223
|
+
try {
|
|
224
|
+
if (!sid) {
|
|
225
|
+
const ensured = await ensureSession(o, signal, hooks)
|
|
226
|
+
sid = ensured.sessionId
|
|
227
|
+
}
|
|
228
|
+
const before = await cursorOf(o, sid, signal)
|
|
229
|
+
await rpc(o, 'session/prompt', {
|
|
230
|
+
requestId: `voice-say-${randomUUID()}`,
|
|
231
|
+
sessionId: sid,
|
|
232
|
+
mode: 'queue',
|
|
233
|
+
content: [{ type: 'text', text }],
|
|
234
|
+
}, signal)
|
|
235
|
+
|
|
236
|
+
let turns = 0
|
|
237
|
+
while (Date.now() < deadline) {
|
|
238
|
+
await new Promise((r) => setTimeout(r, o.pollMs))
|
|
239
|
+
if (signal?.aborted) throw new Error('aborted')
|
|
240
|
+
let cur = 0
|
|
241
|
+
try {
|
|
242
|
+
cur = await cursorOf(o, sid, signal)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (/past cursor/.test(String(error?.message))) continue
|
|
245
|
+
throw error
|
|
246
|
+
}
|
|
247
|
+
if (cur <= before) continue
|
|
248
|
+
const page = await rpc(o, 'session/page', {
|
|
249
|
+
address: { kind: 'session', sessionId: sid }, throughSeq: cur, maxMessages: 30,
|
|
250
|
+
}, signal)
|
|
251
|
+
const events = (page?.records || []).map((r) => r?.event).filter(Boolean)
|
|
252
|
+
const finished = new Set(
|
|
253
|
+
events.filter((e) => e.type === 'turn/end').map((e) => e.data?.turn),
|
|
254
|
+
)
|
|
255
|
+
const mine = events
|
|
256
|
+
.filter((e) => e.type === 'assistant/message' && Number(e.seq) > before)
|
|
257
|
+
.filter((e) => finished.has(e.data?.turn))
|
|
258
|
+
turns = Math.max(turns, mine.length)
|
|
259
|
+
if (mine.length) {
|
|
260
|
+
const last = mine[mine.length - 1]
|
|
261
|
+
const answer = textOfAssistant(last).trim()
|
|
262
|
+
if (answer) {
|
|
263
|
+
return { ok: true, sessionId: sid, text: answer, elapsedMs: Date.now() - started, turns }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { ok: false, sessionId: sid, text: '', elapsedMs: Date.now() - started, turns, error: 'timeout:会话里这一轮没在超时前说完' }
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false, sessionId: sid, text: '', elapsedMs: Date.now() - started,
|
|
271
|
+
turns: 0, error: String(error?.message || error),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 给状态面板看的摘要(不发请求,只看能不能用)。 */
|
|
277
|
+
export function probeDsh(cfg = {}) {
|
|
278
|
+
const o = dshOptions(cfg)
|
|
279
|
+
return {
|
|
280
|
+
enabled: o.enabled,
|
|
281
|
+
ready: o.ready,
|
|
282
|
+
baseUrl: o.baseUrl,
|
|
283
|
+
baseFrom: o.baseFrom,
|
|
284
|
+
hasSecret: o.hasSecret,
|
|
285
|
+
sessionId: o.sessionId,
|
|
286
|
+
title: o.title,
|
|
287
|
+
cwd: o.cwd,
|
|
288
|
+
timeoutMs: o.timeoutMs,
|
|
289
|
+
}
|
|
290
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-voice-say",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "dsh-voice-say:把一句话合成语音。两条通道(本地 Breeze TTS 2 / 云端 apiKey+voiceId)共用 instructions + guidanceScale;18
|
|
5
|
+
"description": "dsh-voice-say:把一句话合成语音。两条通道(本地 Breeze TTS 2 / 云端 apiKey+voiceId)共用 instructions + guidanceScale;18 个风格可叠加;合成前由「读音导演」重排标点——默认在 DeepSeek 窗口里的专用会话里想,按人设、当下情绪与对话情节断句;支持上传本地音色、自定义参考音频与试听片段。QQ 桥接会先问插件,群里发出去的语音与设置页试听同源同风格。",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "dsh-voice-say contributors",
|
|
8
8
|
"keywords": [
|