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 +22 -0
- package/index.js +3 -1
- package/lib/tools.js +141 -4
- package/lib/voice.js +3 -9
- package/lib/vts.js +65 -0
- package/package.json +3 -2
- package/runtime/say.py +14 -1
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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.
|
|
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(
|
|
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
|