dsh-pet-live2d 2.1.0 → 2.3.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/CHANGELOG.md +75 -0
- package/README.md +44 -2
- package/lib/client.js +690 -39
- package/lib/index.js +175 -13
- package/package.json +1 -1
- package/pets/ds-whale-girl/pet.json +74 -0
package/lib/index.js
CHANGED
|
@@ -274,6 +274,88 @@ function normaliseSlots(raw) {
|
|
|
274
274
|
return out
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/**
|
|
278
|
+
* 名字里带这些字的部件算"头":作者在 cdi3 里给每个部件起了中文名
|
|
279
|
+
* (`Part46` = 脸蛋、`Part57/58` = 眼睛、`Part14` = 头发、`Part45/68` = 耳朵…)。
|
|
280
|
+
*/
|
|
281
|
+
const HEAD_PART_NAME_HINTS = /头|脸|面|眼|眉|嘴|耳|发|eye|face|hair|head|ear|brow|mouth|cheek|nose/i
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 名字里带这些字的部件算"尾巴/翅膀"(这只宠物有 15 个:狐狸尾 / 猫尾 / 狼尾 / 大翅膀…,
|
|
285
|
+
* 都是可换的配件,同一时刻只有一个是显形的)。
|
|
286
|
+
*/
|
|
287
|
+
const TAIL_PART_NAME_HINTS = /尾|鳍|翅|翼|fin|tail|wing/i
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 从 cdi3 里按名字挑部件。
|
|
291
|
+
*
|
|
292
|
+
* 为什么要读 cdi3:摸头/摸尾巴要知道"哪些 drawable 是头、是尾巴"。原来浏览器半区用的是
|
|
293
|
+
* 写死的英文正则(`face|eye|mouth|…`),而这只模型的 drawable id 是 `Part46` 这种 ——
|
|
294
|
+
* **一个都匹配不上**,于是判定静默退化成"点哪都算头"。作者自己的中文名才是权威分类。
|
|
295
|
+
*
|
|
296
|
+
* @returns {string[]} 部件 id;没有 cdi3 或没挑到就是空数组(浏览器半区会退回旧行为)。
|
|
297
|
+
*/
|
|
298
|
+
function readPartsMatching(dir, modelPath, hints) {
|
|
299
|
+
try {
|
|
300
|
+
const base = modelPath.replace(/\.model3\.json$/i, '')
|
|
301
|
+
const file = findCdi3(dir, base)
|
|
302
|
+
if (file === undefined) return []
|
|
303
|
+
const cdi3 = readJson(file)
|
|
304
|
+
const parts = cdi3?.Parts
|
|
305
|
+
if (!Array.isArray(parts)) return []
|
|
306
|
+
const out = []
|
|
307
|
+
for (const part of parts) {
|
|
308
|
+
if (part === null || typeof part !== 'object') continue
|
|
309
|
+
const id = typeof part.Id === 'string' ? part.Id : undefined
|
|
310
|
+
if (id === undefined || id === '') continue
|
|
311
|
+
const name = typeof part.Name === 'string' ? part.Name : ''
|
|
312
|
+
if (!hints.test(name) && !hints.test(id)) continue
|
|
313
|
+
out.push(id)
|
|
314
|
+
}
|
|
315
|
+
return out
|
|
316
|
+
} catch {
|
|
317
|
+
return []
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 找 cdi3.json。
|
|
323
|
+
*
|
|
324
|
+
* 它**不在** model3.json 的引用里(那是 Cubism Editor 的元数据,运行时不读),所以只能
|
|
325
|
+
* 按约定找:与模型同名的 `<base>.cdi3.json` 优先,其次模型旁边、再其次宠物目录和它的
|
|
326
|
+
* 直接子目录 —— 这只宠物就放在 `model/` 子目录里,只在根目录找会一个都挑不到。
|
|
327
|
+
*/
|
|
328
|
+
function findCdi3(dir, base) {
|
|
329
|
+
const direct = [join(dir, base + '.cdi3.json'), join(dir, 'model', base + '.cdi3.json')]
|
|
330
|
+
for (const file of direct) {
|
|
331
|
+
if (existsSync(file)) return file
|
|
332
|
+
}
|
|
333
|
+
const roots = [dir]
|
|
334
|
+
try {
|
|
335
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
336
|
+
if (entry.isDirectory() && entry.name !== 'node_modules') roots.push(join(dir, entry.name))
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
/* 读不了就只用 dir 本身 */
|
|
340
|
+
}
|
|
341
|
+
for (const root of roots) {
|
|
342
|
+
try {
|
|
343
|
+
const hit = readdirSync(root).find((name) => name.toLowerCase().endsWith('.cdi3.json'))
|
|
344
|
+
if (hit !== undefined) return join(root, hit)
|
|
345
|
+
} catch {
|
|
346
|
+
/* 跳过读不了的目录 */
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 互动反应候选:一串标签(动作的中文名,或表情名)。非字符串/空的都丢掉。
|
|
354
|
+
*/
|
|
355
|
+
function readReactionList(value) {
|
|
356
|
+
return Array.isArray(value) ? value.filter((name) => typeof name === 'string' && name !== '') : []
|
|
357
|
+
}
|
|
358
|
+
|
|
277
359
|
/** Scan one pet directory into a catalog entry, or undefined when unusable. */
|
|
278
360
|
export function scanPet(dir, id) {
|
|
279
361
|
const manifestFile = join(dir, 'pet.json')
|
|
@@ -426,6 +508,17 @@ export function scanPet(dir, id) {
|
|
|
426
508
|
// premise is missing (a selfie with no phone out, a spray with no whale)
|
|
427
509
|
// must not play at all, from ANY path: the panel, a fidget or a phase.
|
|
428
510
|
motionGuards: typeof block.motionGuards === 'object' && block.motionGuards !== null ? block.motionGuards : {},
|
|
511
|
+
// 头部 / 尾巴部件(部件 id):摸头、摸尾巴按这些部件的**真实几何**判定。
|
|
512
|
+
// 空数组 = 没 cdi3 或没挑到,浏览器半区退回旧行为。
|
|
513
|
+
headParts: readPartsMatching(dir, modelPath, HEAD_PART_NAME_HINTS),
|
|
514
|
+
tailParts: readPartsMatching(dir, modelPath, TAIL_PART_NAME_HINTS),
|
|
515
|
+
// 台词:宠物自己的声音(问候 / 点击 / 摸头 / 摸尾巴 / 转晕 / 归位 / 每个相位)。
|
|
516
|
+
// 用户能在设置里逐条改,改过的存浏览器;这里是**默认值**。
|
|
517
|
+
lines: typeof block.lines === 'object' && block.lines !== null ? block.lines : {},
|
|
518
|
+
// 互动反应候选(标签:动作的中文名或表情名)。摸头 / 摸尾巴 / 转晕各一组。
|
|
519
|
+
patReactions: readReactionList(block.patReactions),
|
|
520
|
+
tailReactions: readReactionList(block.tailReactions),
|
|
521
|
+
spinReactions: readReactionList(block.spinReactions),
|
|
429
522
|
// 摸鱼默认盯哪几个槽位。以前这六个是**写死在浏览器半区**的("宠物自己的身子"),
|
|
430
523
|
// 但宠物作者(和用户)会想改:这只宠物把「自拍」也放进了摸鱼池。写在这里之后,
|
|
431
524
|
// "默认集合"也成了宠物自己声明的东西,用户加的槽位照样覆盖在上面。
|
|
@@ -624,6 +717,12 @@ function catalogRoute() {
|
|
|
624
717
|
expressionSlots: pet.expressionSlots,
|
|
625
718
|
looksByPhase: pet.looksByPhase,
|
|
626
719
|
motionGuards: pet.motionGuards,
|
|
720
|
+
headParts: pet.headParts,
|
|
721
|
+
tailParts: pet.tailParts,
|
|
722
|
+
lines: pet.lines,
|
|
723
|
+
patReactions: pet.patReactions,
|
|
724
|
+
tailReactions: pet.tailReactions,
|
|
725
|
+
spinReactions: pet.spinReactions,
|
|
627
726
|
fidgetSlots: pet.fidgetSlots,
|
|
628
727
|
hiddenMotions: pet.hiddenMotions,
|
|
629
728
|
motionOptions: pet.motionOptions,
|
|
@@ -803,11 +902,16 @@ function runtimeRoute() {
|
|
|
803
902
|
// comment frame.
|
|
804
903
|
|
|
805
904
|
/** The activity phases the browser half understands. */
|
|
806
|
-
export const ACTIVITY_PHASES = [
|
|
905
|
+
export const ACTIVITY_PHASES = [
|
|
906
|
+
'idle', 'thinking', 'waiting', 'asking', 'tool', 'helper', 'queued', 'done', 'failed',
|
|
907
|
+
]
|
|
807
908
|
|
|
808
909
|
/** How long the 'done' celebration is held before falling back to idle. */
|
|
809
910
|
const DONE_HOLD_MS = 3500
|
|
810
911
|
|
|
912
|
+
/** "你的消息排队了"给一个短促的收到反应,然后回到原来在演的东西。 */
|
|
913
|
+
const QUEUED_HOLD_MS = 1600
|
|
914
|
+
|
|
811
915
|
/** SSE keep-alive interval. */
|
|
812
916
|
const SSE_PING_MS = 30000
|
|
813
917
|
|
|
@@ -820,7 +924,7 @@ export class ActivityHub {
|
|
|
820
924
|
this.phase = 'idle'
|
|
821
925
|
this.detail = ''
|
|
822
926
|
this.listeners = new Set()
|
|
823
|
-
this.
|
|
927
|
+
this.holdTimer = undefined
|
|
824
928
|
}
|
|
825
929
|
|
|
826
930
|
/** Current snapshot (sent as the first frame of every stream). */
|
|
@@ -841,19 +945,30 @@ export class ActivityHub {
|
|
|
841
945
|
this.emit()
|
|
842
946
|
}
|
|
843
947
|
|
|
948
|
+
/**
|
|
949
|
+
* 演一个**短促反应**相位,过一会儿自动回落到 `fallback`。
|
|
950
|
+
*
|
|
951
|
+
* `done`(庆祝)和 `queued`(收到你排队的消息)都是这一类:宠物演一下,然后回到
|
|
952
|
+
* 它该在的地方。回落之前会确认相位没被别人接管 —— 这段时间里工具开始了、或者
|
|
953
|
+
* 新一轮说话了,就不要再把它拽回旧的 fallback。
|
|
954
|
+
*/
|
|
955
|
+
hold(phase, ms, fallback = 'idle') {
|
|
956
|
+
if (this.holdTimer !== undefined) clearTimeout(this.holdTimer)
|
|
957
|
+
this.set(phase, '')
|
|
958
|
+
this.holdTimer = setTimeout(() => {
|
|
959
|
+
this.holdTimer = undefined
|
|
960
|
+
if (this.phase === phase) this.set(fallback, '')
|
|
961
|
+
}, ms)
|
|
962
|
+
// Keep the process free to exit; the timer is purely cosmetic.
|
|
963
|
+
this.holdTimer?.unref?.()
|
|
964
|
+
}
|
|
965
|
+
|
|
844
966
|
/**
|
|
845
967
|
* Enter the 'done' celebration and fall back to idle after a hold, so the
|
|
846
968
|
* pet visibly finishes a turn instead of snapping straight back.
|
|
847
969
|
*/
|
|
848
970
|
celebrate() {
|
|
849
|
-
|
|
850
|
-
this.set('done', '')
|
|
851
|
-
this.doneTimer = setTimeout(() => {
|
|
852
|
-
this.doneTimer = undefined
|
|
853
|
-
this.set('idle', '')
|
|
854
|
-
}, DONE_HOLD_MS)
|
|
855
|
-
// Keep the process free to exit; the timer is purely cosmetic.
|
|
856
|
-
this.doneTimer?.unref?.()
|
|
971
|
+
this.hold('done', DONE_HOLD_MS, 'idle')
|
|
857
972
|
}
|
|
858
973
|
|
|
859
974
|
emit() {
|
|
@@ -868,9 +983,9 @@ export class ActivityHub {
|
|
|
868
983
|
}
|
|
869
984
|
|
|
870
985
|
dispose() {
|
|
871
|
-
if (this.
|
|
872
|
-
clearTimeout(this.
|
|
873
|
-
this.
|
|
986
|
+
if (this.holdTimer !== undefined) {
|
|
987
|
+
clearTimeout(this.holdTimer)
|
|
988
|
+
this.holdTimer = undefined
|
|
874
989
|
}
|
|
875
990
|
this.listeners.clear()
|
|
876
991
|
}
|
|
@@ -958,6 +1073,53 @@ export function attachActivityEvents(ctx, hub) {
|
|
|
958
1073
|
}, TOOL_IDLE_MS)
|
|
959
1074
|
return next()
|
|
960
1075
|
})
|
|
1076
|
+
|
|
1077
|
+
// ---- DSH 0.1.7 里另外三个值得接的状态 --------------------------------------
|
|
1078
|
+
//
|
|
1079
|
+
// 事件词汇表在 0.1.7 里已经不小(`*.d.ts` 里声明了 100 个可订阅事件),但只有一部分
|
|
1080
|
+
// 能翻译成"宠物该演什么"。挑的标准是**它填的是不是一个真实的空档**:
|
|
1081
|
+
//
|
|
1082
|
+
// asking ← user-questions/request:以前"在等你回答问题"这段时间宠物还在演"干活",
|
|
1083
|
+
// 看着像它没停过 ✗ —— 这是最大的一个空档;
|
|
1084
|
+
// helper ← subagent/start|end:子代理是**长活**(几分钟),和一次普通工具调用
|
|
1085
|
+
// 混在一起看不出区别;
|
|
1086
|
+
// queued ← agent/inbox/inserted:你发的话在它忙的时候插进来,现在至少给个"收到"。
|
|
1087
|
+
//
|
|
1088
|
+
// 没接的(评估过,不值得):`fs/write-intent` / `edit-intent` —— 宠物在 tool 相位已经
|
|
1089
|
+
// 演「写本本」了,再拆一个 editing 和它重复;`workflow/*` —— 工作流本身是工具调用,
|
|
1090
|
+
// 已被 tool 覆盖(`workflow/phase` 的标题倒是可以当 detail,但宠物目前不显示 detail);
|
|
1091
|
+
// `agent/status` 只有 idle / running 两个值,给不出更细的东西。
|
|
1092
|
+
|
|
1093
|
+
// `user-questions/request` 是 waterfall:链一直挂到**你把问题答完**才 resume,所以
|
|
1094
|
+
// 这个相位正好等于"卡在等你"的那段,不用自己计时。
|
|
1095
|
+
onWaterfall('user-questions/request', (_request, next) => {
|
|
1096
|
+
hub.set('asking', '')
|
|
1097
|
+
const answered = typeof next === 'function' ? next() : undefined
|
|
1098
|
+
const back = () => {
|
|
1099
|
+
if (hub.phase === 'asking') hub.set('thinking', '')
|
|
1100
|
+
}
|
|
1101
|
+
if (answered !== null && typeof answered?.then === 'function') {
|
|
1102
|
+
answered.then(back, back)
|
|
1103
|
+
return answered
|
|
1104
|
+
}
|
|
1105
|
+
back()
|
|
1106
|
+
return answered
|
|
1107
|
+
})
|
|
1108
|
+
on('subagent/start', (info) => {
|
|
1109
|
+
const label = typeof info?.name === 'string'
|
|
1110
|
+
? info.name
|
|
1111
|
+
: (typeof info?.label === 'string' ? info.label : '')
|
|
1112
|
+
hub.set('helper', label)
|
|
1113
|
+
})
|
|
1114
|
+
on('subagent/end', () => {
|
|
1115
|
+
if (hub.phase === 'helper') hub.set('thinking', '')
|
|
1116
|
+
})
|
|
1117
|
+
on('agent/inbox/inserted', () => {
|
|
1118
|
+
// 回到"刚才在演的那个"(可能是 tool / asking,也可能是 idle)——不要一律回 idle,
|
|
1119
|
+
// 那会让一个正在跑工具的回合看起来停了。
|
|
1120
|
+
const previous = hub.phase
|
|
1121
|
+
hub.hold('queued', QUEUED_HOLD_MS, previous === 'queued' ? 'thinking' : previous)
|
|
1122
|
+
})
|
|
961
1123
|
}
|
|
962
1124
|
|
|
963
1125
|
/** The activity stream + snapshot route. */
|
package/package.json
CHANGED
|
@@ -568,6 +568,22 @@
|
|
|
568
568
|
"lhand": "橡皮",
|
|
569
569
|
"symbol": "问号"
|
|
570
570
|
},
|
|
571
|
+
"asking": {
|
|
572
|
+
"whale": "头顶鲸",
|
|
573
|
+
"rhand": "双手比耶",
|
|
574
|
+
"mood": "开心兴奋",
|
|
575
|
+
"symbol": "问号"
|
|
576
|
+
},
|
|
577
|
+
"helper": {
|
|
578
|
+
"whale": "头顶鲸",
|
|
579
|
+
"rhand": "喵喵手",
|
|
580
|
+
"mood": "调皮"
|
|
581
|
+
},
|
|
582
|
+
"queued": {
|
|
583
|
+
"whale": "头顶鲸",
|
|
584
|
+
"mood": "开心兴奋",
|
|
585
|
+
"symbol": "感叹号"
|
|
586
|
+
},
|
|
571
587
|
"done": {
|
|
572
588
|
"whale": "头顶鲸",
|
|
573
589
|
"lhand": "橡皮",
|
|
@@ -594,6 +610,64 @@
|
|
|
594
610
|
]
|
|
595
611
|
}
|
|
596
612
|
},
|
|
613
|
+
"lines": {
|
|
614
|
+
"greet": [
|
|
615
|
+
"你好呀,我是鲸鱼娘~",
|
|
616
|
+
"今天也一起加油吧!",
|
|
617
|
+
"终于见到你了",
|
|
618
|
+
"摸鱼时间到?"
|
|
619
|
+
],
|
|
620
|
+
"click": [
|
|
621
|
+
"呀!",
|
|
622
|
+
"痒痒的~",
|
|
623
|
+
"干嘛呀",
|
|
624
|
+
"摸摸头?",
|
|
625
|
+
"嘿嘿"
|
|
626
|
+
],
|
|
627
|
+
"pat": [
|
|
628
|
+
"嘿嘿,好舒服~",
|
|
629
|
+
"再摸一下嘛",
|
|
630
|
+
"唔…头发要乱了"
|
|
631
|
+
],
|
|
632
|
+
"tail": [
|
|
633
|
+
"呀!尾巴不能乱摸!",
|
|
634
|
+
"尾巴…痒痒的",
|
|
635
|
+
"别拽尾巴啦"
|
|
636
|
+
],
|
|
637
|
+
"spin": [
|
|
638
|
+
"呜…转晕了…",
|
|
639
|
+
"别转了别转了"
|
|
640
|
+
],
|
|
641
|
+
"reset": [
|
|
642
|
+
"表情归位~",
|
|
643
|
+
"清清爽爽"
|
|
644
|
+
],
|
|
645
|
+
"loadFailed": [
|
|
646
|
+
"呜呜,模型加载失败了"
|
|
647
|
+
],
|
|
648
|
+
"phase": {
|
|
649
|
+
"thinking": "让我想想…",
|
|
650
|
+
"tool": "正在忙…",
|
|
651
|
+
"waiting": "需要你确认一下",
|
|
652
|
+
"asking": "在等你回答哦",
|
|
653
|
+
"helper": "叫了帮手一起弄",
|
|
654
|
+
"queued": "收到,记下了",
|
|
655
|
+
"done": "搞定!",
|
|
656
|
+
"failed": "呜…出错了"
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
"patReactions": [
|
|
660
|
+
"重锤出击",
|
|
661
|
+
"问号",
|
|
662
|
+
"星星眼"
|
|
663
|
+
],
|
|
664
|
+
"tailReactions": [
|
|
665
|
+
"吐魂",
|
|
666
|
+
"问号"
|
|
667
|
+
],
|
|
668
|
+
"spinReactions": [
|
|
669
|
+
"晕晕"
|
|
670
|
+
],
|
|
597
671
|
"hiddenMotions": [
|
|
598
672
|
"OpenCase",
|
|
599
673
|
"BubbleGum"
|