dsh-pet-live2d 2.0.2 → 2.2.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 +46 -0
- package/README.md +29 -2
- package/lib/client.js +144 -51
- package/lib/index.js +76 -13
- package/package.json +1 -1
- package/pets/ds-whale-girl/pet.json +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 2.2.0
|
|
4
|
+
|
|
5
|
+
### 多接三个会话状态(对着 DSH 0.1.7 的事件表挑的)
|
|
6
|
+
|
|
7
|
+
0.1.7 里能订阅的事件有 100 个,但只有一部分能翻译成"宠物该演什么"。挑的标准是
|
|
8
|
+
**它填的是不是一个真实的空档**:
|
|
9
|
+
|
|
10
|
+
| 新相位 | 来自 | 以前的样子 |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `asking` | `user-questions/request` | 它问你问题、等你回答的整段时间里,宠物还在演"干活" —— 最大的一个空档 |
|
|
13
|
+
| `helper` | `subagent/start` / `subagent/end` | 子代理是**长活**(几分钟),和一次普通工具调用混在一起看不出区别 |
|
|
14
|
+
| `queued` | `agent/inbox/inserted` | 你发的话在它忙的时候排队,宠物毫无反应 |
|
|
15
|
+
|
|
16
|
+
`asking` 挂的是 waterfall:链一直挂到你答完才 resume,所以这个相位**正好**等于"卡在等你"
|
|
17
|
+
的那一段,不用自己计时。`queued` 是短促反应(1.6 秒),回落到**排队前那个相位**而不是
|
|
18
|
+
一律回 idle —— 否则一个正在跑工具的回合看起来像停了。
|
|
19
|
+
|
|
20
|
+
**没接的**(评估过,理由写在 `lib/index.js` 的注释里):`fs/write-intent` / `edit-intent`
|
|
21
|
+
(tool 相位已经演「写本本」了,再拆一个 editing 和它重复)、`workflow/*`(工作流本身就是
|
|
22
|
+
工具调用,已被 tool 覆盖)、`agent/status`(只有 idle / running 两个值,给不出更细的)。
|
|
23
|
+
|
|
24
|
+
## 2.1.0
|
|
25
|
+
|
|
26
|
+
### 会话相位改成真正的"接管"
|
|
27
|
+
|
|
28
|
+
用户报的:"我在爱心眼冒爱心掏出手机自拍的时候进入会话状态,右手会停在手机状态且一直冒爱心。"
|
|
29
|
+
|
|
30
|
+
原来相位**只接管它点名的槽位**,其余槽位继续用你自己的选择 —— 于是那套"手机 + 爱心"
|
|
31
|
+
在整场会话里一直挂着。现在相位一开始,宠物换相位那一套样子:
|
|
32
|
+
|
|
33
|
+
- 相位点名的槽位用它抽到的;
|
|
34
|
+
- 相位**没点名**的槽位**一律让位**(收回);挂在槽位上的定格动作(掏出手机)也跟着
|
|
35
|
+
交还身体,手放下来 —— 手机停着不放是有两个原因叠在一起:槽位没让位,而且
|
|
36
|
+
"替别的槽位保姿势"那份名单相位走不到,一直每帧把手机姿势写回去;
|
|
37
|
+
- **装扮槽永远归你**(眼镜 / 发饰 / 魔爪 / 巴菲 / 桌布 / 手机换色),相位不碰;
|
|
38
|
+
- 会话进行中手点某个槽位会**临时盖过**相位的那一格(以前点了没反应,因为相位压着它);
|
|
39
|
+
- 会话结束后你原来的样子回来 —— 让位不是删除。
|
|
40
|
+
|
|
41
|
+
### 右键面板的「装扮」跟着相位走
|
|
42
|
+
|
|
43
|
+
用户报的:"会话的状态没有在右键菜单的装扮里同步 button。"
|
|
44
|
+
|
|
45
|
+
面板原来读的是**你自己的选择**,相位接管后画面上是相位的样子、面板还显示你上次手选的。
|
|
46
|
+
现在两边读同一个"此刻显示什么"(`effectiveSlotChoice`):相位点名用相位的,会话中没点名
|
|
47
|
+
的整格空着,装扮槽永远显示你自己的。
|
|
48
|
+
|
|
3
49
|
## 2.0.2
|
|
4
50
|
|
|
5
51
|
**只改文档**(宠物自带的那份 README,`pets/ds-whale-girl/README.md`)。它落后得比主 README
|
package/README.md
CHANGED
|
@@ -74,8 +74,24 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
|
|
|
74
74
|
|
|
75
75
|
### 会话相位:同一套池子
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
每个会话相位下面是"槽位 → 条目表",机制和摸鱼一样。**宠物跟着 DSH 的真实事件换相位**:
|
|
78
|
+
|
|
79
|
+
| 相位 | 什么时候 | 这只宠物演什么 |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `thinking` | 模型在想 / 刚开工 | 抬头看 |
|
|
82
|
+
| `tool` | 调工具(detail 带工具名) | 写本本 + 流汗 |
|
|
83
|
+
| `waiting` | 有操作**等你批准** | 问号 |
|
|
84
|
+
| `asking` | 它**问了你一个问题**、在等你回答 | 举手 + 问号 |
|
|
85
|
+
| `helper` | 起了**子代理**(一段长活) | 喵喵手 + 调皮 |
|
|
86
|
+
| `queued` | 你发的话**排队**了(它还在忙) | 感叹号(短促一下) |
|
|
87
|
+
| `done` | 这一轮说完(庆祝 3.5 秒) | 开心 + 情绪花花 |
|
|
88
|
+
| `failed` | 出错了 | 大哭 + 流汗 |
|
|
89
|
+
|
|
90
|
+
来源是 `agent/status`、`tools/pre-execute|post-execute`、`approval/request`、
|
|
91
|
+
`user-questions/request`、`subagent/start|end`、`agent/inbox/inserted`、
|
|
92
|
+
`agent/turn-stopping`、`agent/error` —— 按主题折成上面这几个相位。
|
|
93
|
+
|
|
94
|
+
相位表**一开始就列出全部相位**,显示的是**有效池子**:
|
|
79
95
|
|
|
80
96
|
| 行头标记 | 含义 | 那一行有什么 |
|
|
81
97
|
|---|---|---|
|
|
@@ -85,6 +101,17 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
|
|
|
85
101
|
改动**只在真的编辑时才落盘**,所以"看一眼"不会留下覆盖 —— 以后宠物更新了默认值,
|
|
86
102
|
没改过的相位照样吃得到。
|
|
87
103
|
|
|
104
|
+
**相位是"接管",不是"叠加"**:会话一开始,宠物换相位那一套样子 ——
|
|
105
|
+
|
|
106
|
+
- 相位**点名**的槽位用它抽到的(`thinking` 就是头顶鲸 + 画笔);
|
|
107
|
+
- 相位**没点名**的槽位**一律让位**(收回):你手选的掏出手机、爱心眼、自拍不会在整场
|
|
108
|
+
会话里一直挂着。挂在槽位上的动作(掏出手机是定格动作)也会跟着交还身体,手放下来;
|
|
109
|
+
- **装扮槽永远归你**:眼镜 / 发饰 / 魔爪 / 巴菲 / 桌布 / 手机换色 会话不碰;
|
|
110
|
+
- 会话进行中想改也能改:手点一下会**临时盖过**相位的那一格(这场会话里有效);
|
|
111
|
+
- 会话**结束**后,你原来的样子回来(让位不是删除)。
|
|
112
|
+
|
|
113
|
+
右键面板的「装扮」页签跟着相位走 —— 会话中它显示的是**相位抽到的**,不是你上次手选的。
|
|
114
|
+
|
|
88
115
|
### 关系:同时(pairs)与前提(requires)
|
|
89
116
|
|
|
90
117
|
条目下面缩进那一层是**关系**,两种刻意用不同前缀:
|
package/lib/client.js
CHANGED
|
@@ -3248,7 +3248,18 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3248
3248
|
// 相位池和摸鱼池是同一套抽签,所以诊断也照抄摸鱼的形状:`phaseNow` 立刻重抽
|
|
3249
3249
|
// 一次(不用等真的相位切换),`phaseTally` 给出每个相位各抽中了什么。
|
|
3250
3250
|
// 没有这两个的话,"池子里是随机的"就只能靠反复推 SSE 再数,慢且会抖。
|
|
3251
|
-
|
|
3251
|
+
/**
|
|
3252
|
+
* 诊断:直接应用一个相位(测试用)。
|
|
3253
|
+
*
|
|
3254
|
+
* **连状态一起设**,跟真实 SSE 路径一致:只调 applyPhase 的话 `data-phase` 还是
|
|
3255
|
+
* 旧值、React 也不会重渲染 —— 面板跟着相位走这件事就测不出来(而且测出来的行为
|
|
3256
|
+
* 跟真实路径不一样)。
|
|
3257
|
+
*/
|
|
3258
|
+
api.phaseNow = (phase) => {
|
|
3259
|
+
if (typeof phase !== "string") return;
|
|
3260
|
+
setPhaseState(phase);
|
|
3261
|
+
flushPhaseRef.current?.(phase);
|
|
3262
|
+
};
|
|
3252
3263
|
api.phaseTally = () => phaseTallyRef.current;
|
|
3253
3264
|
api.resetPhaseTally = () => { phaseTallyRef.current = {}; };
|
|
3254
3265
|
// 诊断:**有效关系**(pet.json + 用户覆盖合并后的结果)与存档里的覆盖。
|
|
@@ -3809,6 +3820,114 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3809
3820
|
motion.current.playOnce(group, index, { kind: "panel" });
|
|
3810
3821
|
}, []);
|
|
3811
3822
|
|
|
3823
|
+
/**
|
|
3824
|
+
* 一个槽位**此刻**显示的是什么 —— 面板高亮、配对、动作守卫三处都读它。
|
|
3825
|
+
*
|
|
3826
|
+
* 三段:
|
|
3827
|
+
* 1. **装扮槽**永远归用户:眼镜/发饰/魔爪/巴菲/桌布/手机换色是"穿在身上的",
|
|
3828
|
+
* 会话相位不碰它们(用户明确要求),哪怕相位的池子里点了名;
|
|
3829
|
+
* 2. 相位**点名**了这个槽位 → 用相位的选择(可能是 null = 相位说这里空着);
|
|
3830
|
+
* 3. 会话进行中、而相位没点名 → **空**:相位是"接管",没点名的槽位也要让位。
|
|
3831
|
+
* 以前这里退回用户的选择,于是"爱心眼 + 冒爱心 + 掏出手机"在整场会话里一直
|
|
3832
|
+
* 挂着(用户报的"会话时没有把插槽重置");
|
|
3833
|
+
* 4. 其余 → 用户自己的选择。
|
|
3834
|
+
*/
|
|
3835
|
+
const effectiveSlotChoice = (slotId) => {
|
|
3836
|
+
if (OUTFIT_SLOTS.indexOf(slotId) !== -1) return slotSelectionsRef.current[slotId];
|
|
3837
|
+
if (Object.prototype.hasOwnProperty.call(phaseChoicesRef.current, slotId)) {
|
|
3838
|
+
return phaseChoicesRef.current[slotId];
|
|
3839
|
+
}
|
|
3840
|
+
if (phaseRef.current !== "idle") return undefined;
|
|
3841
|
+
return slotSelectionsRef.current[slotId];
|
|
3842
|
+
};
|
|
3843
|
+
|
|
3844
|
+
/** 某个槽位此刻挂着哪个动作组(读**有效选择**)。 */
|
|
3845
|
+
const motionGroupOfSlot = (slotId) => {
|
|
3846
|
+
const label = effectiveSlotChoice(slotId);
|
|
3847
|
+
if (label === undefined || label === null) return null;
|
|
3848
|
+
const picked = (petRef.current?.expressionSlots ?? [])
|
|
3849
|
+
.find((slot) => slot.id === slotId)?.options.find((o) => o.label === label);
|
|
3850
|
+
return typeof picked?.motion === "string" ? picked.motion : null;
|
|
3851
|
+
};
|
|
3852
|
+
|
|
3853
|
+
/**
|
|
3854
|
+
* 身体此刻该播哪个动作 —— 按**有效选择**扫一遍槽位。
|
|
3855
|
+
*
|
|
3856
|
+
* "最后点的那个槽位"优先(`motionOwnerRef`),它不再是动作选项时回退到"扫描全部槽位
|
|
3857
|
+
* 取第一个带 motion 的"。只用扫描会在"右手拿着手机时点吹泡泡糖"时把后者静默忽略
|
|
3858
|
+
* (右手在清单里排在嘴部之前),只用记忆会在槽位被换掉后留下陈旧值。
|
|
3859
|
+
*/
|
|
3860
|
+
const desiredSlotMotion = () => {
|
|
3861
|
+
const owner = motionOwnerRef.current;
|
|
3862
|
+
if (owner !== null) {
|
|
3863
|
+
const owned = motionGroupOfSlot(owner);
|
|
3864
|
+
if (owned !== null) return owned;
|
|
3865
|
+
}
|
|
3866
|
+
for (const other of petRef.current?.expressionSlots ?? []) {
|
|
3867
|
+
const found = motionGroupOfSlot(other.id);
|
|
3868
|
+
if (found !== null) return found;
|
|
3869
|
+
}
|
|
3870
|
+
return null;
|
|
3871
|
+
};
|
|
3872
|
+
|
|
3873
|
+
/**
|
|
3874
|
+
* 重算"替哪些*别的*槽位保姿势"。
|
|
3875
|
+
*
|
|
3876
|
+
* 身体只有一个动作,但姿势可以同时存在(掏出手机 + 吹泡泡糖,两组参数不相交),
|
|
3877
|
+
* 所以除了当前演的那个,其余还挂着动作的槽位要把最后一帧写回去。
|
|
3878
|
+
*
|
|
3879
|
+
* **相位接管后也要重算**:那时候用户的槽位动作已经让位,姿势就不该再保着 —— 不然
|
|
3880
|
+
* 会一直举着手机(用户报的"进入会话状态右手会停在手机状态",根因就是这份名单只在
|
|
3881
|
+
* `chooseSlotOption` 里更新,相位走不到)。
|
|
3882
|
+
*/
|
|
3883
|
+
const syncKeptPoses = (playing) => {
|
|
3884
|
+
const keepGroups = [];
|
|
3885
|
+
for (const other of petRef.current?.expressionSlots ?? []) {
|
|
3886
|
+
const found = motionGroupOfSlot(other.id);
|
|
3887
|
+
if (found !== null && found !== playing && keepGroups.indexOf(found) === -1) keepGroups.push(found);
|
|
3888
|
+
}
|
|
3889
|
+
motion.current.setKeptPoses(keepGroups);
|
|
3890
|
+
};
|
|
3891
|
+
|
|
3892
|
+
/**
|
|
3893
|
+
* 把身体切到"有效选择要求的那一个动作"。
|
|
3894
|
+
*
|
|
3895
|
+
* **槽位点选和相位接管共用这一个**:相位接管时用户的槽位让位,挂在槽位上的
|
|
3896
|
+
* `hold + persist` 动作(掏出手机/吹泡泡糖/自拍)必须跟着交还身体 —— 不然手会一直
|
|
3897
|
+
* 举着手机(用户报的"进入会话状态右手会停在手机状态")。相位接管以前不走这条,
|
|
3898
|
+
* 于是那只手谁也放不下来。
|
|
3899
|
+
*/
|
|
3900
|
+
const syncSlotMotion = () => {
|
|
3901
|
+
const desired = desiredSlotMotion();
|
|
3902
|
+
const previous = slotMotionRef.current;
|
|
3903
|
+
slotMotionRef.current = desired;
|
|
3904
|
+
// 只在"该播的动作真的换了"时才播 —— 原来还有个 `|| option !== null`,
|
|
3905
|
+
// 意思是点任何表情都顺手把当前动作重播一遍。它会**重新快照**,而这时
|
|
3906
|
+
// 动作早就在最后一帧停着了:掏出手机之后点爱心眼,快照里的 phone 记的就是
|
|
3907
|
+
// 1(手机已在手里),于是"还原"忠实地把手机举着不放。
|
|
3908
|
+
// 用户报的"掏出手机切不到其他状态"就是这个。
|
|
3909
|
+
if (desired !== null && desired !== previous) {
|
|
3910
|
+
motion.current.playOnce(desired, 0, { kind: "slot", hold: true, persist: true });
|
|
3911
|
+
} else if (desired === null && previous !== null) {
|
|
3912
|
+
// The slot gave up its motion: hand the body back. Other slots' pins
|
|
3913
|
+
// are untouched, so their look survives.
|
|
3914
|
+
motion.current.playIdle();
|
|
3915
|
+
}
|
|
3916
|
+
};
|
|
3917
|
+
|
|
3918
|
+
/**
|
|
3919
|
+
* 会话进行中,手点(或配对带出)某个槽位时,**同时**改掉相位的这一格。
|
|
3920
|
+
*
|
|
3921
|
+
* 相位接管着这个槽位,只改用户的选择是看不见的 —— 用户会以为"点了没反应"。
|
|
3922
|
+
* 覆盖只在这一场会话里有效:下一条相位消息来了就重新抽(`applyPhase` 从空开始)。
|
|
3923
|
+
* 装扮槽不参与(它永远归用户,相位本来也压不过它)。
|
|
3924
|
+
*/
|
|
3925
|
+
const markPhaseOverride = (slotId, label) => {
|
|
3926
|
+
if (phaseRef.current === "idle") return;
|
|
3927
|
+
if (OUTFIT_SLOTS.indexOf(slotId) !== -1) return;
|
|
3928
|
+
phaseChoicesRef.current[slotId] = label === undefined ? null : label;
|
|
3929
|
+
};
|
|
3930
|
+
|
|
3812
3931
|
/**
|
|
3813
3932
|
* 「同时」(pairs) 的**不变量**:把当前选择里所有配对的目标表达式补进来。
|
|
3814
3933
|
*
|
|
@@ -3819,14 +3938,12 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3819
3938
|
* 放在这里而不是 commitPins:**这是所有路径都经过的那个漏斗**(面板点选、摸鱼抽中、
|
|
3820
3939
|
* 相位切换、归位、换宠物),只有一处的规则才不会走岔。
|
|
3821
3940
|
*
|
|
3822
|
-
*
|
|
3823
|
-
* 还会硬把配对目标钉在脸上(相位结束后自动回来)。
|
|
3941
|
+
* 源头取**有效选择**(`effectiveSlotChoice`):相位接管中以相位为准。
|
|
3824
3942
|
*/
|
|
3825
3943
|
const pairPinsNow = () => {
|
|
3826
3944
|
const pins = {};
|
|
3827
3945
|
for (const slotId of slotByIdRef.current.keys()) {
|
|
3828
|
-
const
|
|
3829
|
-
const label = owns ? phaseChoicesRef.current[slotId] : slotSelectionsRef.current[slotId];
|
|
3946
|
+
const label = effectiveSlotChoice(slotId);
|
|
3830
3947
|
if (label === null || label === undefined) continue;
|
|
3831
3948
|
for (const [targetSlot, targetLabel] of Object.entries(relationsOf(slotId, label).pairs)) {
|
|
3832
3949
|
const target = slotByIdRef.current.get(targetSlot)?.options.find((o) => o.label === targetLabel);
|
|
@@ -4038,6 +4155,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4038
4155
|
if (wanted) chosen[slotId] = label;
|
|
4039
4156
|
else delete chosen[slotId];
|
|
4040
4157
|
slotSelectionsRef.current = chosen;
|
|
4158
|
+
markPhaseOverride(slotId, wanted ? label : null);
|
|
4041
4159
|
saveOutfit();
|
|
4042
4160
|
if (!wanted && typeof target.options.find((o) => o.label === label)?.motion === "string") {
|
|
4043
4161
|
slotMotionRef.current = null;
|
|
@@ -4111,6 +4229,9 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4111
4229
|
if (option === null) delete chosen[slot.id];
|
|
4112
4230
|
else chosen[slot.id] = option.label;
|
|
4113
4231
|
slotSelectionsRef.current = chosen;
|
|
4232
|
+
// 会话进行中点的:相位正接管着这个槽位,只改用户选择是**看不见的** —— 同时把
|
|
4233
|
+
// 相位的这一格也改掉(只在这一场会话里有效,下一条相位消息来了就重抽)。
|
|
4234
|
+
markPhaseOverride(slot.id, option === null ? null : option.label);
|
|
4114
4235
|
applyExpressions(next);
|
|
4115
4236
|
// A motion attached to a slot plays and PARKS on its last frame, so the
|
|
4116
4237
|
// chosen look stays put instead of dropping back to the idle loop.
|
|
@@ -4125,51 +4246,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4125
4246
|
else userSweepRef.current = option.sweep;
|
|
4126
4247
|
applySweep();
|
|
4127
4248
|
// 身体只有一个动作。**最后点的那个槽位**优先(见 motionOwnerRef),它不再是动作
|
|
4128
|
-
//
|
|
4129
|
-
//
|
|
4130
|
-
const motionOf = (slotId) => {
|
|
4131
|
-
const label = slotSelectionsRef.current[slotId];
|
|
4132
|
-
if (label === undefined) return null;
|
|
4133
|
-
const picked = (petRef.current?.expressionSlots ?? [])
|
|
4134
|
-
.find((slot) => slot.id === slotId)?.options.find((o) => o.label === label);
|
|
4135
|
-
return typeof picked?.motion === "string" ? picked.motion : null;
|
|
4136
|
-
};
|
|
4249
|
+
// 选项时回退到扫描 —— 规则收在 `desiredSlotMotion()` 里,相位接管走的是同一个。
|
|
4250
|
+
//
|
|
4137
4251
|
// 先把归属改掉,再算 desired:否则"刚点的这个"要等下一次点击才生效。
|
|
4138
4252
|
if (option !== null && typeof option.motion === "string") motionOwnerRef.current = slot.id;
|
|
4139
4253
|
else if (motionOwnerRef.current === slot.id) motionOwnerRef.current = null;
|
|
4140
|
-
|
|
4141
|
-
if (desired === null) {
|
|
4142
|
-
for (const other of petRef.current?.expressionSlots ?? []) {
|
|
4143
|
-
const found = motionOf(other.id);
|
|
4144
|
-
if (found !== null) {
|
|
4145
|
-
desired = found;
|
|
4146
|
-
break;
|
|
4147
|
-
}
|
|
4148
|
-
}
|
|
4149
|
-
}
|
|
4254
|
+
const desired = desiredSlotMotion();
|
|
4150
4255
|
// 身体只有一个动作,但**姿势可以同时存在**:别的槽位还选着动作时,替它们把最后
|
|
4151
4256
|
// 一帧的姿势写回去(掏出手机 + 吹泡泡糖,两组参数不相交)。清掉那个槽位就等于把
|
|
4152
4257
|
// 它从名单里去掉,它写过的手会交还出去。
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
const found = motionOf(other.id);
|
|
4156
|
-
if (found !== null && found !== desired && keepGroups.indexOf(found) === -1) keepGroups.push(found);
|
|
4157
|
-
}
|
|
4158
|
-
motion.current.setKeptPoses(keepGroups);
|
|
4159
|
-
const previous = slotMotionRef.current;
|
|
4160
|
-
slotMotionRef.current = desired;
|
|
4161
|
-
// 只在"该播的动作真的换了"时才播 —— 原来还有个 `|| option !== null`,
|
|
4162
|
-
// 意思是点任何表情都顺手把当前动作重播一遍。它会**重新快照**,而这时
|
|
4163
|
-
// 动作早就在最后一帧停着了:掏出手机之后点爱心眼,快照里的 phone 记的就是
|
|
4164
|
-
// 1(手机已在手里),于是"还原"忠实地把手机举着不放。
|
|
4165
|
-
// 用户报的"掏出手机切不到其他状态"就是这个。
|
|
4166
|
-
if (desired !== null && desired !== previous) {
|
|
4167
|
-
motion.current.playOnce(desired, 0, { kind: "slot", hold: true, persist: true });
|
|
4168
|
-
} else if (desired === null && previous !== null) {
|
|
4169
|
-
// The slot gave up its motion: hand the body back. Other slots' pins
|
|
4170
|
-
// are untouched, so their look survives.
|
|
4171
|
-
motion.current.playIdle();
|
|
4172
|
-
}
|
|
4258
|
+
syncKeptPoses(desired);
|
|
4259
|
+
syncSlotMotion();
|
|
4173
4260
|
// A dress-up choice PERSISTS. The auto-clear exists so a reaction or a
|
|
4174
4261
|
// session phase cannot leave the pet stuck, but an outfit is an explicit
|
|
4175
4262
|
// choice the user reverses from this panel (or with 归位), and expiring it
|
|
@@ -4256,6 +4343,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4256
4343
|
* 三轮不动就收手。
|
|
4257
4344
|
*/
|
|
4258
4345
|
const applyPhase = (phase) => {
|
|
4346
|
+
// 相位名记在这里(而不是只记在 SSE 回调里):`effectiveSlotChoice` 靠它判断
|
|
4347
|
+
// "会话进行中",而诊断读口 `phaseNow()` 是直接调这个函数的 —— 分开写的话,
|
|
4348
|
+
// 诊断走的相位不会让槽位让位,测出来的行为跟真实路径不一样。
|
|
4349
|
+
phaseRef.current = phase;
|
|
4259
4350
|
const slotById = new Map((pet?.expressionSlots ?? []).map((slot) => [slot.id, slot]));
|
|
4260
4351
|
const pools = phasePoolsFor(phase);
|
|
4261
4352
|
// 从**空**开始,而不是继承上一个相位的答案:相位是接管,不是叠加。
|
|
@@ -4367,6 +4458,9 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4367
4458
|
}
|
|
4368
4459
|
phaseGroupRef.current[phase] = group;
|
|
4369
4460
|
phasePoolsRev.current = JSON.stringify(pools);
|
|
4461
|
+
// 相位接管后重算"替谁保姿势":用户那套槽位动作已经让位,姿势就不该再保着,
|
|
4462
|
+
// 否则手会一直举着手机。名单过去只在 chooseSlotOption 里更新,相位走不到。
|
|
4463
|
+
syncKeptPoses(group);
|
|
4370
4464
|
const sustained = PHASE_SUSTAIN.indexOf(phase) !== -1;
|
|
4371
4465
|
if (phase === "idle" || group === undefined) {
|
|
4372
4466
|
// No motion for this phase: stop sustaining and return to rest.
|
|
@@ -4395,13 +4489,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4395
4489
|
// selections, so it stays true while the look keeps the phone out and goes
|
|
4396
4490
|
// false the moment the slot changes.
|
|
4397
4491
|
motion.current.setGuardResolver((group) => {
|
|
4398
|
-
// 相位"抽空了"这个槽位时不能退回用户的选择:owns 为真且值是 null 就是空。
|
|
4399
|
-
const chosenOf = (slotId) => {
|
|
4400
|
-
const owns = Object.prototype.hasOwnProperty.call(phaseChoicesRef.current, slotId);
|
|
4401
|
-
return owns ? phaseChoicesRef.current[slotId] : slotSelectionsRef.current[slotId];
|
|
4402
|
-
};
|
|
4403
4492
|
const holds = (slotId, labels) => {
|
|
4404
|
-
const chosen =
|
|
4493
|
+
const chosen = effectiveSlotChoice(slotId);
|
|
4405
4494
|
return chosen !== undefined && chosen !== null && labels.includes(chosen);
|
|
4406
4495
|
};
|
|
4407
4496
|
const guard = guardsRef.current[group];
|
|
@@ -4937,7 +5026,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4937
5026
|
// "every expression is pinned": a motion-only option has an EMPTY
|
|
4938
5027
|
// expression list, and [].every(...) is vacuously true, so
|
|
4939
5028
|
// 掏出手机 and 吹泡泡糖 rendered as permanently pressed.
|
|
4940
|
-
|
|
5029
|
+
//
|
|
5030
|
+
// 读的是**有效选择**(`effectiveSlotChoice`):会话相位接管时,面板要跟着
|
|
5031
|
+
// 显示相位抽到什么,而不是你上一次手选的 —— 以前两边脱节,用户报过
|
|
5032
|
+
// "会话的状态没有在右键菜单的装扮里同步 button"。
|
|
5033
|
+
const chosenLabel = effectiveSlotChoice(slot.id);
|
|
4941
5034
|
const active = slot.options.find((option) => option.label === chosenLabel);
|
|
4942
5035
|
return h("div", { "data-group": "", key: slot.id, "data-slot": slot.id },
|
|
4943
5036
|
h("span", null, slot.label),
|
package/lib/index.js
CHANGED
|
@@ -803,11 +803,16 @@ function runtimeRoute() {
|
|
|
803
803
|
// comment frame.
|
|
804
804
|
|
|
805
805
|
/** The activity phases the browser half understands. */
|
|
806
|
-
export const ACTIVITY_PHASES = [
|
|
806
|
+
export const ACTIVITY_PHASES = [
|
|
807
|
+
'idle', 'thinking', 'waiting', 'asking', 'tool', 'helper', 'queued', 'done', 'failed',
|
|
808
|
+
]
|
|
807
809
|
|
|
808
810
|
/** How long the 'done' celebration is held before falling back to idle. */
|
|
809
811
|
const DONE_HOLD_MS = 3500
|
|
810
812
|
|
|
813
|
+
/** "你的消息排队了"给一个短促的收到反应,然后回到原来在演的东西。 */
|
|
814
|
+
const QUEUED_HOLD_MS = 1600
|
|
815
|
+
|
|
811
816
|
/** SSE keep-alive interval. */
|
|
812
817
|
const SSE_PING_MS = 30000
|
|
813
818
|
|
|
@@ -820,7 +825,7 @@ export class ActivityHub {
|
|
|
820
825
|
this.phase = 'idle'
|
|
821
826
|
this.detail = ''
|
|
822
827
|
this.listeners = new Set()
|
|
823
|
-
this.
|
|
828
|
+
this.holdTimer = undefined
|
|
824
829
|
}
|
|
825
830
|
|
|
826
831
|
/** Current snapshot (sent as the first frame of every stream). */
|
|
@@ -841,19 +846,30 @@ export class ActivityHub {
|
|
|
841
846
|
this.emit()
|
|
842
847
|
}
|
|
843
848
|
|
|
849
|
+
/**
|
|
850
|
+
* 演一个**短促反应**相位,过一会儿自动回落到 `fallback`。
|
|
851
|
+
*
|
|
852
|
+
* `done`(庆祝)和 `queued`(收到你排队的消息)都是这一类:宠物演一下,然后回到
|
|
853
|
+
* 它该在的地方。回落之前会确认相位没被别人接管 —— 这段时间里工具开始了、或者
|
|
854
|
+
* 新一轮说话了,就不要再把它拽回旧的 fallback。
|
|
855
|
+
*/
|
|
856
|
+
hold(phase, ms, fallback = 'idle') {
|
|
857
|
+
if (this.holdTimer !== undefined) clearTimeout(this.holdTimer)
|
|
858
|
+
this.set(phase, '')
|
|
859
|
+
this.holdTimer = setTimeout(() => {
|
|
860
|
+
this.holdTimer = undefined
|
|
861
|
+
if (this.phase === phase) this.set(fallback, '')
|
|
862
|
+
}, ms)
|
|
863
|
+
// Keep the process free to exit; the timer is purely cosmetic.
|
|
864
|
+
this.holdTimer?.unref?.()
|
|
865
|
+
}
|
|
866
|
+
|
|
844
867
|
/**
|
|
845
868
|
* Enter the 'done' celebration and fall back to idle after a hold, so the
|
|
846
869
|
* pet visibly finishes a turn instead of snapping straight back.
|
|
847
870
|
*/
|
|
848
871
|
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?.()
|
|
872
|
+
this.hold('done', DONE_HOLD_MS, 'idle')
|
|
857
873
|
}
|
|
858
874
|
|
|
859
875
|
emit() {
|
|
@@ -868,9 +884,9 @@ export class ActivityHub {
|
|
|
868
884
|
}
|
|
869
885
|
|
|
870
886
|
dispose() {
|
|
871
|
-
if (this.
|
|
872
|
-
clearTimeout(this.
|
|
873
|
-
this.
|
|
887
|
+
if (this.holdTimer !== undefined) {
|
|
888
|
+
clearTimeout(this.holdTimer)
|
|
889
|
+
this.holdTimer = undefined
|
|
874
890
|
}
|
|
875
891
|
this.listeners.clear()
|
|
876
892
|
}
|
|
@@ -958,6 +974,53 @@ export function attachActivityEvents(ctx, hub) {
|
|
|
958
974
|
}, TOOL_IDLE_MS)
|
|
959
975
|
return next()
|
|
960
976
|
})
|
|
977
|
+
|
|
978
|
+
// ---- DSH 0.1.7 里另外三个值得接的状态 --------------------------------------
|
|
979
|
+
//
|
|
980
|
+
// 事件词汇表在 0.1.7 里已经不小(`*.d.ts` 里声明了 100 个可订阅事件),但只有一部分
|
|
981
|
+
// 能翻译成"宠物该演什么"。挑的标准是**它填的是不是一个真实的空档**:
|
|
982
|
+
//
|
|
983
|
+
// asking ← user-questions/request:以前"在等你回答问题"这段时间宠物还在演"干活",
|
|
984
|
+
// 看着像它没停过 ✗ —— 这是最大的一个空档;
|
|
985
|
+
// helper ← subagent/start|end:子代理是**长活**(几分钟),和一次普通工具调用
|
|
986
|
+
// 混在一起看不出区别;
|
|
987
|
+
// queued ← agent/inbox/inserted:你发的话在它忙的时候插进来,现在至少给个"收到"。
|
|
988
|
+
//
|
|
989
|
+
// 没接的(评估过,不值得):`fs/write-intent` / `edit-intent` —— 宠物在 tool 相位已经
|
|
990
|
+
// 演「写本本」了,再拆一个 editing 和它重复;`workflow/*` —— 工作流本身是工具调用,
|
|
991
|
+
// 已被 tool 覆盖(`workflow/phase` 的标题倒是可以当 detail,但宠物目前不显示 detail);
|
|
992
|
+
// `agent/status` 只有 idle / running 两个值,给不出更细的东西。
|
|
993
|
+
|
|
994
|
+
// `user-questions/request` 是 waterfall:链一直挂到**你把问题答完**才 resume,所以
|
|
995
|
+
// 这个相位正好等于"卡在等你"的那段,不用自己计时。
|
|
996
|
+
onWaterfall('user-questions/request', (_request, next) => {
|
|
997
|
+
hub.set('asking', '')
|
|
998
|
+
const answered = typeof next === 'function' ? next() : undefined
|
|
999
|
+
const back = () => {
|
|
1000
|
+
if (hub.phase === 'asking') hub.set('thinking', '')
|
|
1001
|
+
}
|
|
1002
|
+
if (answered !== null && typeof answered?.then === 'function') {
|
|
1003
|
+
answered.then(back, back)
|
|
1004
|
+
return answered
|
|
1005
|
+
}
|
|
1006
|
+
back()
|
|
1007
|
+
return answered
|
|
1008
|
+
})
|
|
1009
|
+
on('subagent/start', (info) => {
|
|
1010
|
+
const label = typeof info?.name === 'string'
|
|
1011
|
+
? info.name
|
|
1012
|
+
: (typeof info?.label === 'string' ? info.label : '')
|
|
1013
|
+
hub.set('helper', label)
|
|
1014
|
+
})
|
|
1015
|
+
on('subagent/end', () => {
|
|
1016
|
+
if (hub.phase === 'helper') hub.set('thinking', '')
|
|
1017
|
+
})
|
|
1018
|
+
on('agent/inbox/inserted', () => {
|
|
1019
|
+
// 回到"刚才在演的那个"(可能是 tool / asking,也可能是 idle)——不要一律回 idle,
|
|
1020
|
+
// 那会让一个正在跑工具的回合看起来停了。
|
|
1021
|
+
const previous = hub.phase
|
|
1022
|
+
hub.hold('queued', QUEUED_HOLD_MS, previous === 'queued' ? 'thinking' : previous)
|
|
1023
|
+
})
|
|
961
1024
|
}
|
|
962
1025
|
|
|
963
1026
|
/** 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": "橡皮",
|