dsh-whale-live 0.2.1 → 0.2.3

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 — 2026-11-12
4
+
5
+ ### Added
6
+ - **Watch toggle in the panel.** The Prepare tab now has a "Watch chat / Stop watching" button, so the host can start or stop the 4-second danmaku poll without asking the model. The toggle reads the live state from `/state` and writes through `action=watch`.
7
+ - The watch timer is shared between the model tool and the panel, so either side can start/stop it; the model is still the one woken by `followup()`.
8
+
9
+ ### Notes
10
+ - Starting watch requires an open session; it stops when the session closes or the plugin reloads.
11
+ - Falls back to `inject` (context only) if `followup` is unavailable.
12
+ ## 0.2.2 — 2026-11-12
13
+
14
+ ### Added
15
+ - **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.
16
+ - The plugin now captures the calling Agent from tool execution so it can be woken later.
17
+
18
+ ### Notes
19
+ - If `followup` is unavailable in a future DSH version, the plugin falls back to `inject` (context only, no wake) instead of failing the loop.
3
20
  ## 0.2.1 — 2026-11-11
4
21
 
5
22
  ### Fixed
package/client.js CHANGED
@@ -27,6 +27,8 @@ window.__ModuleLoader__.load({
27
27
  prepareHint: '开播前先把设备架好:拉起 VTube Studio / OBS / 直播间页面,只准备,不推流;自检会逐项报告缺什么。',
28
28
  stageBtn: '架好设备',
29
29
  diagnoseBtn: '自检',
30
+ watchStart: '蹲点',
31
+ watchStop: '停止蹲点',
30
32
  prepareSummary: '{ok}/{total} 通过 · {failed} 项必须修 · {warned} 项可选',
31
33
  stageDone: '设备已拉起(没有自动推流)。',
32
34
  stateLive: '直播中',
@@ -75,6 +77,8 @@ window.__ModuleLoader__.load({
75
77
  prepareHint: 'Set the gear up before going live: VTube Studio, OBS and the room page. Preparation only — nothing starts streaming.',
76
78
  stageBtn: 'Set up',
77
79
  diagnoseBtn: 'Self-check',
80
+ watchStart: 'Watch chat',
81
+ watchStop: 'Stop watching',
78
82
  prepareSummary: '{ok}/{total} passed · {failed} required · {warned} optional',
79
83
  stageDone: 'Gear is up (streaming was not started).',
80
84
  stateLive: 'Live',
@@ -452,6 +456,13 @@ window.__ModuleLoader__.load({
452
456
  disabled: diagLoading,
453
457
  onClick: loadDiag,
454
458
  }, diagLoading ? '…' : t('diagnoseBtn')),
459
+ h('button', {
460
+ key: 'watch',
461
+ type: 'button',
462
+ className: 'dshwl-btn',
463
+ disabled: busy !== '' || !session,
464
+ onClick: () => run('watch', 'watch', { step: state?.watch?.running ? 'stop' : 'start' }),
465
+ }, state?.watch?.running ? t('watchStop') : t('watchStart')),
455
466
  session ? null : h('button', {
456
467
  key: 'live',
457
468
  type: 'button',
package/index.js CHANGED
@@ -57,13 +57,14 @@ export async function apply(ctx, config = {}) {
57
57
  const vts = createVts(settings, ctx.logger);
58
58
  const stage = createStage(settings, avatar, ctx.logger);
59
59
  const voice = createVoice(settings, store, ctx.logger, avatar);
60
- const tools = createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, config: settings });
60
+ const watchApi = {};
61
+ const tools = createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, watchApi, config: settings });
61
62
 
62
63
  ctx.effect(() => {
63
64
  const disposers = [];
64
65
  try {
65
66
  for (const tool of tools) disposers.push(ctx.tools.register(tool));
66
- const disposeHttp = registerHttp(ctx, { store, danmaku, voice, avatar, stage, diagnose, config: settings, logger: ctx.logger });
67
+ const disposeHttp = registerHttp(ctx, { store, danmaku, voice, avatar, stage, watchApi, diagnose, config: settings, logger: ctx.logger });
67
68
  if (disposeHttp) disposers.push(disposeHttp);
68
69
  disposers.push(() => voice.dispose());
69
70
  } catch (error) {
package/lib/http.js CHANGED
@@ -83,7 +83,7 @@ function sendJson(response, status, value) {
83
83
  response.end(JSON.stringify(value));
84
84
  }
85
85
 
86
- export function registerHttp(ctx, { store, danmaku, voice, avatar, stage, diagnose, config, logger }) {
86
+ export function registerHttp(ctx, { store, danmaku, voice, avatar, stage, watchApi, diagnose, config, logger }) {
87
87
  async function snapshot() {
88
88
  const state = store.state();
89
89
  const skin = await avatar.status();
@@ -105,6 +105,7 @@ export function registerHttp(ctx, { store, danmaku, voice, avatar, stage, diagno
105
105
  recent: state.session ? store.recentEvents(state.session.id, 80) : [],
106
106
  voice: voice.status(),
107
107
  danmaku: danmaku.status(),
108
+ watch: watchApi?.status?.() ?? null,
108
109
  avatar: skin,
109
110
  config: {
110
111
  roomId: config.roomId,
@@ -150,6 +151,10 @@ export function registerHttp(ctx, { store, danmaku, voice, avatar, stage, diagno
150
151
  if (action === 'stage') {
151
152
  return { stage: await stage.stage() };
152
153
  }
154
+ if (action === 'watch') {
155
+ if (!watchApi?.start) throw Object.assign(new Error('watch 还没就绪'), { code: 'NOT_READY' });
156
+ return { watch: body.step === 'stop' ? watchApi.stop() : watchApi.start() };
157
+ }
153
158
  if (action === 'diagnose') {
154
159
  return { diagnose: await diagnose({ store, danmaku, avatar, stage, config }) };
155
160
  }
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,96 @@ function timeOf(iso) {
31
34
  }
32
35
  }
33
36
 
34
- export function createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, config }) {
37
+ export function createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, watchApi, 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 都收 UserMessage 对象,绝不能再塞裸字符串(会写坏会话日志)。 */
50
+ function wakeAgent(text) {
51
+ const agent = lastAgent;
52
+ if (!agent) return false;
53
+ const message = { content: [{ type: 'text', text }], source: { kind: 'user' } };
54
+ try {
55
+ agent.followup(message);
56
+ return true;
57
+ } catch {
58
+ try {
59
+ agent.inject(message);
60
+ } catch {
61
+ /* 叫不醒也不能影响轮询 */
62
+ }
63
+ return false;
64
+ }
65
+ }
66
+
67
+ async function watchTick() {
68
+ if (watchBusy || !store.state().session) return;
69
+ watchBusy = true;
70
+ try {
71
+ const { messages } = await danmaku.pollOnce();
72
+ if (!messages.length) return;
73
+ for (const message of messages) {
74
+ store.appendEvent({ kind: 'danmaku', user: message.user, text: message.text, self: message.self === true });
75
+ }
76
+ const lines = messages.map((message) => `${message.user}:${message.text}`).join('\n');
77
+ wakeAgent(`[直播间新弹幕]\n${lines}\n\n(去看看,想接就接;不想接也可以记一笔。)`);
78
+ } catch {
79
+ /* 轮询失败下一轮再来 */
80
+ } finally {
81
+ watchBusy = false;
82
+ }
83
+ }
84
+
85
+ // 面板也用它开/关蹲点。
86
+ if (watchApi) {
87
+ watchApi.status = () => ({ running: Boolean(watchTimer), hasAgent: Boolean(lastAgent) });
88
+ watchApi.start = () => {
89
+ if (watchTimer) return { running: true, already: true };
90
+ if (!store.state().session) return { running: false, error: '还没开播' };
91
+ watchTimer = setInterval(watchTick, 4000);
92
+ return { running: true };
93
+ };
94
+ watchApi.stop = () => {
95
+ stopWatch();
96
+ return { running: false };
97
+ };
98
+ watchApi.wake = wakeAgent;
99
+ }
100
+
101
+ /** 探测 Agent 上有什么方法(只记录名字和类型,不调用)。 */
102
+ function probeAgent(agent) {
103
+ if (!agent) return;
104
+ lastAgent = agent;
105
+ try {
106
+ const own = Object.getOwnPropertyNames(agent);
107
+ const proto = Object.getPrototypeOf(agent);
108
+ const protoNames = proto ? Object.getOwnPropertyNames(proto) : [];
109
+ const types = {};
110
+ for (const name of [...new Set([...own, ...protoNames])]) {
111
+ try {
112
+ types[name] = typeof agent[name];
113
+ } catch {
114
+ types[name] = 'unreadable';
115
+ }
116
+ }
117
+ fs.writeFileSync(
118
+ path.join(store.paths.dataDir, 'agent-probe.json'),
119
+ JSON.stringify({ when: new Date().toISOString(), own, proto: protoNames, types }, null, 2),
120
+ 'utf8',
121
+ );
122
+ } catch {
123
+ /* 探测失败不影响正常使用 */
124
+ }
125
+ }
126
+
35
127
  const roomName = config.roomName || '直播间';
36
128
  const roomId = config.roomId;
37
129
 
@@ -84,7 +176,7 @@ export function createTools({ ctx, store, danmaku, voice, avatar, vts, stage, di
84
176
  properties: {
85
177
  action: {
86
178
  type: 'string',
87
- enum: ['open', 'status', 'read', 'note', 'listen', 'close', 'propose', 'memory_list', 'commit', 'reject', 'avatar', 'vts', 'stage', 'diagnose'],
179
+ enum: ['open', 'status', 'read', 'note', 'listen', 'close', 'propose', 'memory_list', 'commit', 'reject', 'avatar', 'vts', 'stage', 'diagnose', 'watch'],
88
180
  description: '要做什么。',
89
181
  },
90
182
  kind: {
@@ -166,7 +258,8 @@ export function createTools({ ctx, store, danmaku, voice, avatar, vts, stage, di
166
258
  timeoutMs: 130000,
167
259
  presentCall: () => ({ card: 'generic', title: '鲸鱼直播台', kind: 'other' }),
168
260
 
169
- async execute(args = {}) {
261
+ async execute(args = {}, exec = {}) {
262
+ probeAgent(exec?.agent);
170
263
  const action = args.action;
171
264
 
172
265
  if (action === 'open') {
@@ -264,6 +357,22 @@ export function createTools({ ctx, store, danmaku, voice, avatar, vts, stage, di
264
357
  return { text: `收到 ${result.messages.length} 条:\n${lines.join('\n')}\n\n接下来看你怎么接。` };
265
358
  }
266
359
 
360
+ if (action === 'watch') {
361
+ const step = args.step || 'start';
362
+ if (step === 'stop') {
363
+ stopWatch();
364
+ return { text: 'watch 已停。' };
365
+ }
366
+ if (step === 'test') {
367
+ const ok = wakeAgent('[测试] 本鲸被叫醒了。');
368
+ return { text: ok ? '叫醒测试已发出,看看本鲸会不会醒。' : '叫醒失败:没有 agent 或 followup 不可用。' };
369
+ }
370
+ if (!store.state().session) return { text: '先 action=open 开一场再 watch。' };
371
+ stopWatch();
372
+ watchTimer = setInterval(watchTick, 4000);
373
+ return { text: 'watch 已开:每 4 秒看一次弹幕,有新话就叫醒本鲸;下播/stop 会停。' };
374
+ }
375
+
267
376
  if (action === 'close') {
268
377
  const meta = store.closeSession({ text: args.text });
269
378
  if (!meta) return { text: '现在没有开着的场。' };
package/lib/watch.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 实时蹲点:轮询弹幕 + 叫醒 Agent。
3
+ * 共享给工具(模型侧)和面板(用户点开关)。
4
+ */
5
+ export function createWatch({ danmaku, store, logger = null }) {
6
+ let timer = null;
7
+ let agent = null;
8
+ let busy = false;
9
+
10
+ function setAgent(value) {
11
+ if (value) agent = value;
12
+ }
13
+
14
+ function wake(text) {
15
+ if (!agent) return false;
16
+ try {
17
+ agent.followup(text);
18
+ return true;
19
+ } catch {
20
+ try {
21
+ agent.followup({ role: 'user', content: text });
22
+ return true;
23
+ } catch {
24
+ try {
25
+ agent.inject(text);
26
+ } catch {
27
+ /* 叫不醒也不能影响轮询 */
28
+ }
29
+ return false;
30
+ }
31
+ }
32
+ }
33
+
34
+ async function tick() {
35
+ if (busy || !store.state().session) return;
36
+ busy = true;
37
+ try {
38
+ const { messages } = await danmaku.pollOnce();
39
+ if (!messages.length) return;
40
+ for (const message of messages) {
41
+ store.appendEvent({ kind: 'danmaku', user: message.user, text: message.text, self: message.self === true });
42
+ }
43
+ const lines = messages.map((message) => `${message.user}:${message.text}`).join('\n');
44
+ wake(`[直播间新弹幕]\n${lines}\n\n(去看看,想接就接;不想接也可以记一笔。)`);
45
+ } catch (error) {
46
+ logger?.warn?.(`whale-live: watch 轮询失败 ${error instanceof Error ? error.message : String(error)}`);
47
+ } finally {
48
+ busy = false;
49
+ }
50
+ }
51
+
52
+ function start() {
53
+ if (timer) return { running: true, already: true };
54
+ if (!store.state().session) return { running: false, error: '还没开播,先 open 或点「开播」' };
55
+ timer = setInterval(tick, 4000);
56
+ return { running: true };
57
+ }
58
+
59
+ function stop() {
60
+ if (timer) clearInterval(timer);
61
+ timer = null;
62
+ return { running: false };
63
+ }
64
+
65
+ function status() {
66
+ return { running: Boolean(timer), hasAgent: Boolean(agent) };
67
+ }
68
+
69
+ return { setAgent, wake, start, stop, status, tick };
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-live",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",