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 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.baseUrl` / `director.model` | `https://ai.xyclub.top/v1` / `deepseek-v4.1-flash` | 导演用的模型(和 QQ 同一个) |
189
- | `director.apiKey` / `apiKeyEnv` | 空 / `DPSK_API_KEY` | key 优先用它,其次环境变量,其次 `~/.dsh/.credentials.yaml` |
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) setDirectorOn(s.director.enabled !== false)
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
- h(Badge, { ok: director.hasKey }, director.hasKey ? (director.keyFrom || '已配置') : '没找到')),
395
- h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '会话'),
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': '' }, '导演缺 key:设置里填 apiKey,或让 DPSK_API_KEY 在环境里;没有 key 时合成照常,只是不重排标点。'),
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.5.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
- ready: Boolean(o.enabled && key),
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
- if (!key) { result.reason = `没找到模型 key(可用 ${o.apiKeyEnv} 环境变量或设置里填 apiKey)`; return result }
453
- result.keyFrom = from
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 callModel(o, key, messages)
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 callModel(
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.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
- "description": "dsh-voice-say:把一句话合成语音。两条通道(本地 Breeze TTS 2 / 云端 apiKey+voiceId)共用 instructions + guidanceScale;18 个风格可叠加;合成前由「读音导演」按人设、当下情绪与对话情节重排标点;支持上传本地音色、自定义参考音频与试听片段。QQ 桥接会先问插件,群里发出去的语音与设置页试听同源同风格。",
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": [