dsh-bili-watch 0.2.8 → 0.2.9

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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * dsh-bili-watch —— 「用户只贴了一条视频链接」的识别器。
3
+ *
4
+ * 只做判定,不做 IO:不请求接口、不抽帧、不写盘。
5
+ * 判定口径见设计文档 T08 第一节:剥壳 → 命中恰好 1 条 → 残余为空。
6
+ */
7
+
8
+ // 完整视频页:允许 ?p=2&t=30 与尾斜杠
9
+ const RE_FULL = /https?:\/\/(?:www\.|m\.)?bilibili\.com\/video\/(BV[0-9A-Za-z]{10})(?:[/?#][^\s<>"'))]*)?/gi
10
+ // 短链:能识别,但 look.mjs 不能展开(tools/look.mjs:1249-1251),调用方要单独处理
11
+ const RE_SHORT = /https?:\/\/b23\.tv\/[0-9A-Za-z]{5,12}(?:[/?#][^\s<>"'))]*)?/gi
12
+ // ?bvid=BV… 形态(tools/look.mjs:1254 支持)
13
+ const RE_BVID_Q = /[?&]bvid=(BV[0-9A-Za-z]{10})/gi
14
+ // 裸 BV 号:前后不许粘字母数字
15
+ const RE_BARE = /(?<![0-9A-Za-z])BV[0-9A-Za-z]{10}(?![0-9A-Za-z])/g
16
+
17
+ // 客户端包在用户文本外的壳;剥掉后剩下的才是用户真打的字
18
+ const WRAPPERS = [
19
+ /<system-reminder[\s\S]*?<\/system-reminder>/gi,
20
+ /<attached_files>[\s\S]*?<\/attached_files>/gi,
21
+ /<user_query>/gi,
22
+ /<\/user_query>/gi,
23
+ ]
24
+ const LINK_ANY = /bilibili\.com\/video\/|b23\.tv\/|BV[0-9A-Za-z]{10}/
25
+
26
+ /** 剥壳;壳里若出现链接,记「引用」(引用不自动触发)。 */
27
+ function stripWrappers(raw) {
28
+ let text = String(raw ?? '')
29
+ let quoted = false
30
+ for (const re of WRAPPERS) {
31
+ text = text.replace(re, (m) => {
32
+ if (LINK_ANY.test(m)) quoted = true
33
+ return ' '
34
+ })
35
+ }
36
+ const kept = []
37
+ for (const ln of text.split('\n')) {
38
+ if (/^\s*>/.test(ln)) {
39
+ if (LINK_ANY.test(ln)) quoted = true
40
+ continue
41
+ }
42
+ kept.push(ln)
43
+ }
44
+ return { text: kept.join('\n'), quoted }
45
+ }
46
+
47
+ /**
48
+ * 判定一条用户文本是否「只有一条视频链接」。
49
+ * @returns {{hit: false, why: string} | {hit: true, kind: 'full'|'short'|'bare', bv: string}}
50
+ */
51
+ export function matchSingleVideoLink(raw) {
52
+ const { text, quoted } = stripWrappers(raw)
53
+ if (quoted) return { hit: false, why: '链接出现在引用/附件里' }
54
+ if (!text.trim()) return { hit: false, why: '空消息' }
55
+ if (/^\s*\//.test(text)) return { hit: false, why: '斜杠命令' }
56
+
57
+ const hits = []
58
+ let m
59
+ RE_FULL.lastIndex = 0
60
+ while ((m = RE_FULL.exec(text))) hits.push({ kind: 'full', bv: m[1], start: m.index, end: m.index + m[0].length })
61
+ RE_SHORT.lastIndex = 0
62
+ while ((m = RE_SHORT.exec(text))) hits.push({ kind: 'short', bv: '', start: m.index, end: m.index + m[0].length })
63
+ RE_BVID_Q.lastIndex = 0
64
+ while ((m = RE_BVID_Q.exec(text))) hits.push({ kind: 'full', bv: m[1], start: m.index, end: m.index + m[0].length })
65
+ RE_BARE.lastIndex = 0
66
+ while ((m = RE_BARE.exec(text))) hits.push({ kind: 'bare', bv: m[0], start: m.index, end: m.index + m[0].length })
67
+
68
+ // 区间去重:整条 URL 覆盖它内部的 BV 号,只算一个
69
+ hits.sort((a, b) => a.start - b.start || b.end - a.end)
70
+ const uniq = []
71
+ for (const x of hits) {
72
+ const last = uniq[uniq.length - 1]
73
+ if (last && x.start < last.end) continue
74
+ uniq.push(x)
75
+ }
76
+ if (uniq.length === 0) return { hit: false, why: '没有视频链接' }
77
+ if (uniq.length > 1) return { hit: false, why: `有 ${uniq.length} 个链接` }
78
+
79
+ const one = uniq[0]
80
+ // 承重墙:链接之外还剩字,就说明用户在说别的事
81
+ const residual = (text.slice(0, one.start) + text.slice(one.end))
82
+ .replace(/\s+/g, '')
83
+ .replace(/[。,、!?!?,.;;::·…~~—\-"'“”‘’()()【】\[\]]/g, '')
84
+ if (residual) return { hit: false, why: `链接外还有字:「${residual.slice(0, 20)}」` }
85
+
86
+ return { hit: true, kind: one.kind, bv: one.bv }
87
+ }
88
+
89
+ /** 从一条 UserMessage 里取纯文本(只认 text 块;有图片/文件块就不算「只贴了链接」)。 */
90
+ export function textOfUserMessage(message) {
91
+ const blocks = Array.isArray(message?.content) ? message.content : []
92
+ if (blocks.some((b) => b && b.type !== 'text')) return { ok: false, text: '' }
93
+ const text = blocks.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n')
94
+ return { ok: true, text }
95
+ }
package/index.js CHANGED
@@ -23,6 +23,17 @@ try {
23
23
  defineTool = null
24
24
  }
25
25
  import QRCode from 'qrcode'
26
+ import { matchSingleVideoLink, textOfUserMessage } from './auto-trigger.js'
27
+ // dsh-llm 同样是可选依赖:拿不到 createUserMessage 就只保留「工具描述」这一层,
28
+ // 不能因为少一个提示能力让整个插件加载失败(同上方 defineTool 的处理方式)
29
+ let createUserMessage = null
30
+ let boundContextSummary = null
31
+ try {
32
+ ({ createUserMessage, boundContextSummary } = await import('@deepseek-ai/dsh-llm'))
33
+ } catch {
34
+ createUserMessage = null
35
+ boundContextSummary = null
36
+ }
26
37
 
27
38
  const HERE = dirname(fileURLToPath(import.meta.url))
28
39
  const LOOK = join(HERE, 'tools', 'look.mjs')
@@ -46,7 +57,7 @@ const HF_HOME = [join(DEPS, '.hf-home'), join(PROJ, '.hf-home')]
46
57
  const SCRIPTS = join(HERE, 'scripts')
47
58
  const CACHE_CFG = join(DEPS, 'cache-config.json')
48
59
  const UI_CFG = join(DEPS, 'ui-config.json')
49
- const DEFAULT_UI_CFG = { hintDismissed: false, activationSeen: false, showInSettings: true }
60
+ const DEFAULT_UI_CFG = { hintDismissed: false, activationSeen: false, showInSettings: true, autoHint: true }
50
61
 
51
62
  function uiCfg() {
52
63
  try {
@@ -81,7 +92,7 @@ const QR_POLL = (key) =>
81
92
  const SPI = 'https://api.bilibili.com/x/frontend/finger/spi'
82
93
 
83
94
  const name = 'dsh-bili-watch'
84
- const inject = ['commands', 'tools', 'webServer']
95
+ const inject = ['commands', 'tools', 'webServer', 'agents']
85
96
 
86
97
  function runLook(args, { timeoutMs = 1_200_000 } = {}) {
87
98
  return new Promise((resolve) => {
@@ -584,6 +595,52 @@ function apply(ctx) {
584
595
  },
585
596
  })
586
597
 
598
+ // ①-b 单条链接软提示:只往消息流里加一条旁白,绝不替用户调用工具
599
+ // 开关落 deps/ui-config.json 的 autoHint(DEFAULT_UI_CFG 见上)
600
+ try {
601
+ if (createUserMessage && uiCfg().autoHint !== false) {
602
+ const hinted = new Map() // message.id → true,同一条消息只提示一次
603
+ const hint = (msg) => {
604
+ const { ok, text } = textOfUserMessage(msg)
605
+ if (!ok) return null
606
+ const r = matchSingleVideoLink(text)
607
+ if (!r.hit) return null
608
+ const line = r.kind === 'short'
609
+ // 短链:look.mjs 不能展开,只让用户展开
610
+ ? '[bili-watch] 这条消息只有一条 b23.tv 短链。本插件不能展开短链,先请用户把浏览器地址栏里的完整链接发过来;不要凭短链猜内容。'
611
+ : `[bili-watch] 这条消息只有一条 B 站视频链接(BV ${r.bv})。要读内容就调 bili_watch(command=peek 最便宜);用户没说要看时不要自己跑。`
612
+ return createUserMessage({
613
+ content: [{ type: 'text', text: line }],
614
+ source: { kind: name, form: 'notice', summary: boundContextSummary?.('检测到单条 B 站视频链接') ?? '检测到单条 B 站视频链接' },
615
+ })
616
+ }
617
+ ctx.on('agent/pre-step', async (payload, next) => {
618
+ const decision = await next()
619
+ try {
620
+ if (decision.kind !== 'enter' || payload.step !== 1) return decision
621
+ if (payload.signal?.aborted) return decision
622
+ const fresh = []
623
+ for (const msg of decision.messages) {
624
+ if (msg?.source?.kind !== 'user') continue
625
+ if (hinted.has(msg.id)) continue
626
+ const extra = hint(msg)
627
+ if (!extra) continue
628
+ hinted.set(msg.id, true)
629
+ fresh.push(extra)
630
+ }
631
+ if (!fresh.length) return decision
632
+ return { ...decision, messages: [...decision.messages, ...fresh] }
633
+ } catch (e) {
634
+ // 提示是旁白,出任何错都不许影响这一步
635
+ try { ctx.logger?.info?.(`[dsh-bili-watch] 自动提示跳过:${e?.message ?? e}`) } catch {}
636
+ return decision
637
+ }
638
+ })
639
+ }
640
+ } catch (e) {
641
+ try { ctx.logger?.info?.(`[dsh-bili-watch] pre-step 钩子没挂上:${e?.message ?? e}`) } catch {}
642
+ }
643
+
587
644
  // ② 同名工具:模型可调,需要看视频时自己来,不依赖用户敲命令
588
645
  // 必须用 defineTool 包一层:ctx.tools.register 收的是编译好的 tool 对象,
589
646
  // 直接传 options 会让发往模型的 parameters 变成 null(报 INVALID_REQUEST: schema must be type "object")。
@@ -736,6 +793,7 @@ function apply(ctx) {
736
793
  if (typeof body?.hintDismissed === 'boolean') patch.hintDismissed = body.hintDismissed
737
794
  if (typeof body?.activationSeen === 'boolean') patch.activationSeen = body.activationSeen
738
795
  if (typeof body?.showInSettings === 'boolean') patch.showInSettings = body.showInSettings
796
+ if (typeof body?.autoHint === 'boolean') patch.autoHint = body.autoHint
739
797
  const next = writeUiCfg(patch)
740
798
  return sendJson(res, 200, { ok: true, data: next })
741
799
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bili-watch",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "private": false,
5
5
  "description": "Reads Bilibili (and generic URLs via yt-dlp) into text for DSH agents: search, hot, metadata, comments, danmaku, AI subtitles, local ASR, frame OCR.",
6
6
  "type": "module",
@@ -33,7 +33,8 @@
33
33
  "tools/",
34
34
  "scripts/",
35
35
  "deps/.gitkeep",
36
- "deps/_bili-asr.py"
36
+ "deps/_bili-asr.py",
37
+ "auto-trigger.js"
37
38
  ],
38
39
  "keywords": [
39
40
  "bilibili",