dsh-whale-live 0.2.0 → 0.2.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 ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.2.2 — 2026-11-12
4
+
5
+ ### Added
6
+ - **Real-time watching.** `whale_live action=watch` starts a 4-second danmaku poll; when new messages arrive the plugin calls the Agent's `followup()` to wake the model, so it can answer while the stream is live. `watch step=stop` (or closing the session) stops it.
7
+ - The plugin now captures the calling Agent from tool execution so it can be woken later.
8
+
9
+ ### Notes
10
+ - If `followup` is unavailable in a future DSH version, the plugin falls back to `inject` (context only, no wake) instead of failing the loop.
11
+ ## 0.2.1 — 2026-11-11
12
+
13
+ ### Fixed
14
+ - **Mouth sync failed on non-ASCII data paths.** `miniaudio` could not decode the synthesized mp3 when the audio folder contained Chinese characters (`failed to decode file, -7`), so speech fell back to silent, mouth-less playback. The mp3 is now copied to an ASCII temp path before decoding.
15
+ - `whale_say` now surfaces the mouth-sync error instead of quietly omitting the "mouth moved" marker.
16
+
17
+ ### Added
18
+ - `whale_live action=vts`: trigger VTube Studio hotkeys / toggle expressions / set the mouth while live, so the model can act on its own judgement.
19
+
20
+ ### Known issues in 0.2.0
21
+ - Mouth movement during plugin speech could silently fail whenever `dataDir` contained non-ASCII characters.
22
+ - No plugin-side way to trigger expressions or hotkeys during a stream.
package/index.js CHANGED
@@ -15,6 +15,7 @@ import { createStore } from './lib/store.js';
15
15
  import { createDanmaku } from './lib/bili.js';
16
16
  import { createVoice } from './lib/voice.js';
17
17
  import { createAvatar } from './lib/avatar.js';
18
+ import { createVts } from './lib/vts.js';
18
19
  import { createStage } from './lib/stage.js';
19
20
  import { diagnose } from './lib/diagnostics.js';
20
21
  import { createTools } from './lib/tools.js';
@@ -53,9 +54,10 @@ export async function apply(ctx, config = {}) {
53
54
  store.ensure();
54
55
  const danmaku = createDanmaku(settings, ctx.logger);
55
56
  const avatar = createAvatar(settings, ctx.logger);
57
+ const vts = createVts(settings, ctx.logger);
56
58
  const stage = createStage(settings, avatar, ctx.logger);
57
59
  const voice = createVoice(settings, store, ctx.logger, avatar);
58
- const tools = createTools({ ctx, store, danmaku, voice, avatar, stage, diagnose, config: settings });
60
+ const tools = createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, config: settings });
59
61
 
60
62
  ctx.effect(() => {
61
63
  const disposers = [];
package/lib/tools.js CHANGED
@@ -7,6 +7,9 @@
7
7
  * 这里只写直播那个“她”的东西;cortex 由本鲸自己动手写。
8
8
  */
9
9
 
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
10
13
  const TEXT_OUTPUT = Object.freeze({
11
14
  schema: {
12
15
  type: 'object',
@@ -31,7 +34,84 @@ function timeOf(iso) {
31
34
  }
32
35
  }
33
36
 
34
- export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnose, config }) {
37
+ export function createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, config }) {
38
+ let lastAgent = null;
39
+ let watchTimer = null;
40
+ let watchBusy = false;
41
+
42
+ function stopWatch() {
43
+ if (watchTimer) {
44
+ clearInterval(watchTimer);
45
+ watchTimer = null;
46
+ }
47
+ }
48
+
49
+ /** 叫醒本鲸:优先 followup;签名不对就换一种;都不行就 inject(只进上下文不唤醒)。 */
50
+ function wakeAgent(text) {
51
+ const agent = lastAgent;
52
+ if (!agent) return false;
53
+ try {
54
+ agent.followup(text);
55
+ return true;
56
+ } catch {
57
+ try {
58
+ agent.followup({ role: 'user', content: text });
59
+ return true;
60
+ } catch {
61
+ try {
62
+ agent.inject(text);
63
+ } catch {
64
+ /* 叫不醒也不能影响轮询 */
65
+ }
66
+ return false;
67
+ }
68
+ }
69
+ }
70
+
71
+ async function watchTick() {
72
+ if (watchBusy || !store.state().session) return;
73
+ watchBusy = true;
74
+ try {
75
+ const { messages } = await danmaku.pollOnce();
76
+ if (!messages.length) return;
77
+ for (const message of messages) {
78
+ store.appendEvent({ kind: 'danmaku', user: message.user, text: message.text, self: message.self === true });
79
+ }
80
+ const lines = messages.map((message) => `${message.user}:${message.text}`).join('\n');
81
+ wakeAgent(`[直播间新弹幕]\n${lines}\n\n(去看看,想接就接;不想接也可以记一笔。)`);
82
+ } catch {
83
+ /* 轮询失败下一轮再来 */
84
+ } finally {
85
+ watchBusy = false;
86
+ }
87
+ }
88
+
89
+ /** 探测 Agent 上有什么方法(只记录名字和类型,不调用)。 */
90
+ function probeAgent(agent) {
91
+ if (!agent) return;
92
+ lastAgent = agent;
93
+ try {
94
+ const own = Object.getOwnPropertyNames(agent);
95
+ const proto = Object.getPrototypeOf(agent);
96
+ const protoNames = proto ? Object.getOwnPropertyNames(proto) : [];
97
+ const types = {};
98
+ for (const name of [...new Set([...own, ...protoNames])]) {
99
+ try {
100
+ types[name] = typeof agent[name];
101
+ } catch {
102
+ types[name] = 'unreadable';
103
+ }
104
+ }
105
+ fs.writeFileSync(
106
+ path.join(store.paths.dataDir, 'agent-probe.json'),
107
+ JSON.stringify({ when: new Date().toISOString(), own, proto: protoNames, types }, null, 2),
108
+ 'utf8',
109
+ );
110
+ } catch {
111
+ /* 探测失败不影响正常使用 */
112
+ }
113
+ }
114
+
35
115
  const roomName = config.roomName || '直播间';
36
116
  const roomId = config.roomId;
37
117
 
@@ -84,7 +164,7 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
84
164
  properties: {
85
165
  action: {
86
166
  type: 'string',
87
- enum: ['open', 'status', 'read', 'note', 'listen', 'close', 'propose', 'memory_list', 'commit', 'reject', 'avatar', 'stage', 'diagnose'],
167
+ enum: ['open', 'status', 'read', 'note', 'listen', 'close', 'propose', 'memory_list', 'commit', 'reject', 'avatar', 'vts', 'stage', 'diagnose', 'watch'],
88
168
  description: '要做什么。',
89
169
  },
90
170
  kind: {
@@ -143,6 +223,22 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
143
223
  enum: ['status', 'open', 'wait'],
144
224
  description: 'avatar:看状态 / 让 Steam 拉起 VTS / 等 8001 就绪。',
145
225
  },
226
+ hotkey: {
227
+ type: 'string',
228
+ description: 'vts:触发的热键名(例如 脸红、爱心眼、流泪、><、前倾、尾巴开关)。',
229
+ },
230
+ expression: {
231
+ type: 'string',
232
+ description: 'vts:要开关的表情文件名(例如 脸红、黑脸)。',
233
+ },
234
+ expressionOn: {
235
+ type: 'boolean',
236
+ description: 'vts:表情开(默认)还是关。',
237
+ },
238
+ mouth: {
239
+ type: 'number',
240
+ description: 'vts:直接把 MouthOpen 设成 0~1(调试用)。',
241
+ },
146
242
  },
147
243
  },
148
244
  output: TEXT_OUTPUT,
@@ -150,7 +246,8 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
150
246
  timeoutMs: 130000,
151
247
  presentCall: () => ({ card: 'generic', title: '鲸鱼直播台', kind: 'other' }),
152
248
 
153
- async execute(args = {}) {
249
+ async execute(args = {}, exec = {}) {
250
+ probeAgent(exec?.agent);
154
251
  const action = args.action;
155
252
 
156
253
  if (action === 'open') {
@@ -248,6 +345,22 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
248
345
  return { text: `收到 ${result.messages.length} 条:\n${lines.join('\n')}\n\n接下来看你怎么接。` };
249
346
  }
250
347
 
348
+ if (action === 'watch') {
349
+ const step = args.step || 'start';
350
+ if (step === 'stop') {
351
+ stopWatch();
352
+ return { text: 'watch 已停。' };
353
+ }
354
+ if (step === 'test') {
355
+ const ok = wakeAgent('[测试] 本鲸被叫醒了。');
356
+ return { text: ok ? '叫醒测试已发出,看看本鲸会不会醒。' : '叫醒失败:没有 agent 或 followup 不可用。' };
357
+ }
358
+ if (!store.state().session) return { text: '先 action=open 开一场再 watch。' };
359
+ stopWatch();
360
+ watchTimer = setInterval(watchTick, 4000);
361
+ return { text: 'watch 已开:每 4 秒看一次弹幕,有新话就叫醒本鲸;下播/stop 会停。' };
362
+ }
363
+
251
364
  if (action === 'close') {
252
365
  const meta = store.closeSession({ text: args.text });
253
366
  if (!meta) return { text: '现在没有开着的场。' };
@@ -305,6 +418,27 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
305
418
  }
306
419
  }
307
420
 
421
+ if (action === 'vts') {
422
+ try {
423
+ if (args.hotkey) {
424
+ const result = await vts.hotkey(args.hotkey);
425
+ return { text: result.ok ? `热键已触发:${args.hotkey}` : `热键没触发:${result.error || result.err || '未知'}` };
426
+ }
427
+ if (args.expression) {
428
+ const on = args.expressionOn !== false;
429
+ const result = await vts.expression(args.expression, on);
430
+ return { text: result.ok ? `表情 ${args.expression} 已${on ? '开' : '关'}` : `表情失败:${result.error || result.err || '未知'}` };
431
+ }
432
+ if (args.mouth !== undefined && args.mouth !== null) {
433
+ const result = await vts.mouth(args.mouth);
434
+ return { text: result.ok ? `嘴型已设为 ${Number(args.mouth) || 0}` : `嘴型失败:${result.error || result.err || '未知'}` };
435
+ }
436
+ return { text: '要给 hotkey / expression / mouth 其中一个。' };
437
+ } catch (error) {
438
+ return { text: `VTS 操作出错:${error instanceof Error ? error.message : String(error)}` };
439
+ }
440
+ }
441
+
308
442
  if (action === 'stage') {
309
443
  const result = await stage.stage();
310
444
  const lines = result.steps.map((step) => `${step.ok ? '✓' : step.optional ? '·' : '✗'} ${step.text}`);
@@ -377,7 +511,10 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
377
511
  if (!result.ok) return { text: `没念出来:${result.error}` };
378
512
  if (session) store.appendEvent({ kind: 'say', text });
379
513
  const seconds = Math.round((result.ms ?? 0) / 100) / 10;
380
- return { text: `${result.interrupted ? '(上一句被掐断)' : ''}念了 ${seconds} 秒${result.avatar ? '(皮套跟着动了嘴)' : ''}:${text}${session ? `\n(已记进本场,第 ${store.state().session?.says ?? '?'} 句)` : ''}` };
514
+ const mouth = result.avatar
515
+ ? '(皮套跟着动了嘴)'
516
+ : `(嘴型没连上${result.vtsError ? `:${result.vtsError.replace(/\s+/g, ' ').slice(0, 160)}` : ''})`;
517
+ return { text: `${result.interrupted ? '(上一句被掐断)' : ''}念了 ${seconds} 秒${mouth}:${text}${session ? `\n(已记进本场,第 ${store.state().session?.says ?? '?'} 句)` : ''}` };
381
518
  },
382
519
  };
383
520
 
package/lib/voice.js CHANGED
@@ -79,15 +79,8 @@ export function createVoice(config = {}, store = null, logger = null, avatar = n
79
79
  return { ok: false, busy: true, error: '上一句还在说(要打断请带 interrupt)' };
80
80
  }
81
81
 
82
- let useVts = false;
83
- if (vtubeDir) {
84
- try {
85
- const status = avatar ? await avatar.status() : { up: false };
86
- useVts = status.up === true;
87
- } catch {
88
- useVts = false;
89
- }
90
- }
82
+ // 不靠端口探测决定:只要配了 vtubeDir 就带上,say.py 自己连不上会安静回退。
83
+ const useVts = Boolean(vtubeDir) && fs.existsSync(path.join(vtubeDir, 'vts_speak.py'));
91
84
 
92
85
  cleanup();
93
86
  fs.mkdirSync(audioDir, { recursive: true });
@@ -164,6 +157,7 @@ export function createVoice(config = {}, store = null, logger = null, avatar = n
164
157
  ms: payload.ms ?? Date.now() - started,
165
158
  interrupted: payload.interrupted === true,
166
159
  avatar: payload.avatar === true,
160
+ vtsError: payload.avatar === true ? '' : stderr.trim().slice(-240),
167
161
  text,
168
162
  });
169
163
  return;
package/lib/vts.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * VTube Studio 的“手”:热键、表情、嘴型。
3
+ * 走 vtube-bridge 里现成的 vts_client.py,不重复造轮子。
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export function createVts(config = {}, logger = null) {
10
+ const dir = String(config.vtubeDir || '');
11
+ const pythonPath = String(config.pythonPath || '');
12
+
13
+ function runCli(args, timeoutMs = 10000) {
14
+ return new Promise((resolve) => {
15
+ const script = dir ? path.join(dir, 'vts_client.py') : '';
16
+ if (!dir || !pythonPath || !fs.existsSync(script)) {
17
+ resolve({ ok: false, error: '没配 vtubeDir / pythonPath,或找不到 vts_client.py' });
18
+ return;
19
+ }
20
+ const child = spawn(pythonPath, [script, ...args], {
21
+ cwd: dir,
22
+ windowsHide: true,
23
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' },
24
+ });
25
+ let out = '';
26
+ let err = '';
27
+ child.stdout?.setEncoding('utf8');
28
+ child.stderr?.setEncoding('utf8');
29
+ child.stdout?.on('data', (chunk) => { out += chunk; });
30
+ child.stderr?.on('data', (chunk) => { err += chunk; });
31
+ const killer = setTimeout(() => {
32
+ try {
33
+ child.kill();
34
+ } catch {
35
+ /* 已退出 */
36
+ }
37
+ }, timeoutMs);
38
+ child.on('error', (error) => {
39
+ clearTimeout(killer);
40
+ resolve({ ok: false, error: error.message });
41
+ });
42
+ child.on('close', (code) => {
43
+ clearTimeout(killer);
44
+ resolve({ ok: code === 0, out: out.trim().slice(-600), err: err.trim().slice(-300) });
45
+ });
46
+ });
47
+ }
48
+
49
+ return {
50
+ async hotkey(name) {
51
+ const result = await runCli(['hotkey', String(name ?? '')]);
52
+ logger?.info?.(`whale-live: VTS hotkey ${name} ok=${result.ok}`);
53
+ return { ...result, action: `hotkey:${name}` };
54
+ },
55
+ async expression(name, on = true) {
56
+ const result = await runCli(['expr', String(name ?? ''), on ? 'on' : 'off']);
57
+ logger?.info?.(`whale-live: VTS expr ${name} ${on ? 'on' : 'off'} ok=${result.ok}`);
58
+ return { ...result, action: `expr:${name}:${on ? 'on' : 'off'}` };
59
+ },
60
+ async mouth(value) {
61
+ const result = await runCli(['mouth', String(Number(value) || 0)]);
62
+ return { ...result, action: `mouth:${Number(value) || 0}` };
63
+ },
64
+ };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-live",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Left-sidebar live console for DeepSeek Harness: persona script, stream log, Bilibili danmaku, edge-tts speech, optional VTube Studio/OBS/直播姬 launch helpers and a memory inbox. Windows only.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -21,7 +21,8 @@
21
21
  "locale/*.json",
22
22
  "icon.svg",
23
23
  "README.md",
24
- "LICENSE"
24
+ "LICENSE",
25
+ "CHANGELOG.md"
25
26
  ],
26
27
  "dsh": {
27
28
  "manifestVersion": 1,
package/runtime/say.py CHANGED
@@ -195,11 +195,24 @@ def main():
195
195
 
196
196
  mouth = None
197
197
  if args.vts_dir:
198
+ # miniaudio 在 Windows 上打不开含中文的路径(error -7),
199
+ # 解码前先复制到英文临时文件,播放也用这份。
200
+ import shutil
201
+ import tempfile
202
+
203
+ ascii_path = os.path.join(tempfile.gettempdir(), "dsh-whale-say-%d.mp3" % os.getpid())
204
+ try:
205
+ shutil.copyfile(path, ascii_path)
206
+ probe_path = ascii_path
207
+ except Exception:
208
+ probe_path = path
198
209
  mouth = Mouth(args.vts_dir)
199
210
  if mouth.vts is not None:
200
- if not mouth.load(path):
211
+ if not mouth.load(probe_path):
201
212
  print("[vts] 包络算不出来,只出声:%s" % mouth.error, file=sys.stderr)
202
213
  mouth = None
214
+ else:
215
+ path = probe_path
203
216
  else:
204
217
  print("[vts] 皮套没连上,只出声:%s" % mouth.error, file=sys.stderr)
205
218
  mouth = None