pet-kit 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +98 -2
- package/cordis.patch.yml +3 -0
- package/index.js +667 -0
- package/package.json +38 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AyAy1314_
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pet-kit —— 给「泡泡」补上投喂、状态和看屏
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
一个 **DSH 插件**(不是 dsh-pet 的分支),给 [dsh-pet](https://github.com/PC2005-cloud/dsh-pet) 加三样它自己没有的功能:
|
|
4
|
+
|
|
5
|
+
| 功能 | 说明 |
|
|
6
|
+
|---|---|
|
|
7
|
+
| **投喂** | 右键「更多 → 投喂」,她吃一顿:饱腹度上涨、播吃饭动画、说一句台词 |
|
|
8
|
+
| **状态** | 右键「更多 → 状态」,她报当前饱腹度和今天吃了什么 |
|
|
9
|
+
| **看眼屏幕** | 右键「更多 → 看眼屏幕」,她真的看一眼你的屏幕,说一句话;这句话**进她的对话记忆**,事后追问能答上 |
|
|
10
|
+
|
|
11
|
+
**不改上游一行代码**:只用 dsh-pet 的公开 HTTP 接口(`/menu/item`、`/menu/events`、`/lock`、`/anim`、
|
|
12
|
+
`/broadcast`、`/chat`)。它升级时,这里通常只需要改一个依赖版本号。
|
|
13
|
+
|
|
14
|
+
## 环境要求
|
|
15
|
+
|
|
16
|
+
- DSH(DeepSeek Harness),带桌面端或网页端
|
|
17
|
+
- **dsh-pet >= 0.4.8**(0.3.x 没有这些接口)
|
|
18
|
+
- Node.js >= 22
|
|
19
|
+
- **ffmpeg**(仅看屏需要;没有就自动禁用看屏并说明原因)
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# 在 DSH profile 目录里
|
|
25
|
+
pnpm add pet-kit
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
然后把包名登记进 profile 的 `dsh.profile.bundles`(见 profile 的 `package.json`)。
|
|
29
|
+
|
|
30
|
+
> 本机开发时可以直接 `file:` 安装 + 在 `node_modules` 里放一个 junction 指到源码目录,
|
|
31
|
+
> 改代码不用重新安装。
|
|
32
|
+
|
|
33
|
+
装完**完全退出 DSH 再打开**(这是宿主侧插件,重载桌面端不够)。
|
|
34
|
+
|
|
35
|
+
## 配置
|
|
36
|
+
|
|
37
|
+
**有设置页**:右键「更多 → 看屏设置」,会在浏览器打开一个本地页面(`http://127.0.0.1:<端口>/pet-kit/settings`),
|
|
38
|
+
改完点保存立刻生效,不用重启。
|
|
39
|
+
|
|
40
|
+
也可以直接改 `~/.dsh/pet-kit/config.json`,首次启动会自动生成:
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
{
|
|
44
|
+
"look": {
|
|
45
|
+
"mode": "passive", // passive = 只有点菜单才看;active = 定时自己看
|
|
46
|
+
"intervalSec": 90, // active 模式的最小间隔
|
|
47
|
+
"dailyBudget": 24, // active 模式每天最多看几次
|
|
48
|
+
"prompt": "(她抬眼看了下屏幕)",
|
|
49
|
+
"ffmpeg": "ffmpeg", // ffmpeg 可执行文件路径;不写就从 PATH 找
|
|
50
|
+
"width": 1600, // 抓帧宽度(越大越贵、越清楚)
|
|
51
|
+
"quality": 4 // JPEG 质量(ffmpeg -q:v,越小越好)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 数据
|
|
57
|
+
|
|
58
|
+
| 文件 | 内容 |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `~/.dsh/pet-kit/feed-state.json` | 饱腹度真值(首次启动会从 `~/.dsh/dsh-pet/feed-state.json` 续上) |
|
|
61
|
+
| `~/.dsh/pet-kit/config.json` | 上面的配置 |
|
|
62
|
+
| `~/.dsh/pet-kit/look-stat.json` | 看屏的每日计数 |
|
|
63
|
+
|
|
64
|
+
截图不进磁盘:抓一帧 JPEG 直接在内存里交给 `/chat`。
|
|
65
|
+
|
|
66
|
+
## HTTP 接口
|
|
67
|
+
|
|
68
|
+
只监听回环,写操作只接受 POST:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
GET /pet-kit/health 插件状态(healthy / detail)
|
|
72
|
+
GET /pet-kit/settings 设置页(HTML)
|
|
73
|
+
GET /pet-kit/config 读当前 look 配置
|
|
74
|
+
POST /pet-kit/config 写 look 配置(白名单校验,只改传了的字段)
|
|
75
|
+
POST /pet-kit/feed?pet=<id> 喂一顿
|
|
76
|
+
POST /pet-kit/status?pet=<id> 报状态
|
|
77
|
+
POST /pet-kit/look?pet=<id> 看一眼前屏
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 已知行为(不是 bug,先说清楚)
|
|
81
|
+
|
|
82
|
+
1. **看屏会出声。** 看屏走 dsh-pet 的 `/chat`,而上游把对话回复当"会念的"——开着语音时,
|
|
83
|
+
看一次说一次。手动点一次念一次;`mode: "active"` 则是每次定时看都念。`/chat` 没有 `speak` 参数,
|
|
84
|
+
调用侧关不掉。默认 `passive` 就是为了让这件事只在你主动点时发生。
|
|
85
|
+
2. **菜单项在右键「更多」下面。** dsh-pet 只给第三方这一层入口。
|
|
86
|
+
3. **看屏需要 ffmpeg**,且**不退回 GDI**:GDI 截视频/游戏是白屏/黑屏。
|
|
87
|
+
4. **游戏要无边框窗口。** 独占全屏不进桌面合成,DXGI 和 Chromium 都抓不到。
|
|
88
|
+
5. **每次看屏 = 一次模型调用**(一张图 + 一句),费用记在你自己的模型账上。
|
|
89
|
+
6. **截图进记忆但不进上下文回放**:她记得自己说过什么,但不等于还能重新看到那张图。
|
|
90
|
+
|
|
91
|
+
## 卸载
|
|
92
|
+
|
|
93
|
+
1. 从 profile 的 `dsh.profile.bundles` 和 `dependencies` 里删掉包名;
|
|
94
|
+
2. 删 `node_modules/pet-kit*`;
|
|
95
|
+
3. 想清数据就删 `~/.dsh/pet-kit/`(饱腹度会从 100% 重来)。
|
|
96
|
+
|
|
97
|
+
## 许可证
|
|
98
|
+
|
|
99
|
+
MIT(见 `LICENSE`)。上游 dsh-pet 也是 MIT。
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pet-kit-local —— 本机版「投喂 / 状态 / 看眼屏幕」
|
|
3
|
+
*
|
|
4
|
+
* 为什么是独立插件:0.4.8 已经原生做了语音/时间/待办/对话,但**没有**饱腹度,
|
|
5
|
+
* 也**没有**"她自己看屏幕"这条主动行为。0.3.8 时代这两块是补丁打进 dsh-pet 包里的,
|
|
6
|
+
* 现在那个补丁退役了,这里用 0.4.8 的公开接口挂回来:
|
|
7
|
+
*
|
|
8
|
+
* 菜单:POST /dsh-pet-7340/menu/item { owner, labels } → 落在右键「更多」下
|
|
9
|
+
* 点击:GET /dsh-pet-7340/menu/events?owner=&wait= → 读取即消费
|
|
10
|
+
* 说话:POST /lock → /anim → /broadcast → /lock/release
|
|
11
|
+
* 看屏:ffmpeg ddagrab 抓一帧 → POST /chat(带 attachment)→ 进她的记忆
|
|
12
|
+
*
|
|
13
|
+
* 不碰 dsh-pet 的任何文件,不改它的包,只走 HTTP。
|
|
14
|
+
* 数据落在 <DSH_HOME>/pet-kit/:feed-state.json(饱腹度)、config.json(看屏设置)、
|
|
15
|
+
* look-stat.json(每日预算计数)。饱腹度首次从 dsh-pet 目录续上,不从 100% 重来。
|
|
16
|
+
*
|
|
17
|
+
* @module pet-kit-local
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export const OWNER = 'pet-kit';
|
|
26
|
+
export const LABELS = { feed: '投喂', status: '状态', look: '看眼屏幕', settings: '看屏设置' };
|
|
27
|
+
|
|
28
|
+
/* ---------------- 饱腹度规则(与 0.3.8 补丁逐字一致) ---------------- */
|
|
29
|
+
const FEED_DEFAULT = 100;
|
|
30
|
+
const FEED_DECAY_PER_HOUR = 5;
|
|
31
|
+
const FEED_DECAY_START_H = 8;
|
|
32
|
+
const FEED_HOUR_MS = 3600000;
|
|
33
|
+
const FEED_MEALS = [
|
|
34
|
+
{ id: 'breakfast', from: 360, to: 570, anim: '吃早餐', add: 20 },
|
|
35
|
+
{ id: 'lunch', from: 690, to: 840, anim: '吃午餐', add: 45 },
|
|
36
|
+
{ id: 'dinner', from: 990, to: 1140, anim: '吃晚餐', add: 35 },
|
|
37
|
+
];
|
|
38
|
+
const FEED_SNACK_ANIMS = ['吃冰淇淋融化', '吃西瓜', '吃糖葫芦'];
|
|
39
|
+
const FEED_LINES = {
|
|
40
|
+
satisfied: ['还吃?你是想让我胖成鲸鱼吗', '唔…就一口,就一口哦'],
|
|
41
|
+
peckish: ['刚刚好,谢啦', '嗯——这个不错'],
|
|
42
|
+
hungry: ['终于…再晚一会儿我就要啃桌角了', '总算想起来了'],
|
|
43
|
+
starving: ['救、救命恩人!', '呜呜,好吃…你是好人'],
|
|
44
|
+
};
|
|
45
|
+
const FEED_AGAIN_LINES = ['刚吃过啦,你是把我当饭桶吗', '又来?我肚子还鼓着呢'];
|
|
46
|
+
const FEED_SNACK_LIMIT_LINES = ['今天的零食额度用完咯,明天再说', '再吃下去我真的要变成球了'];
|
|
47
|
+
const FEED_TIER_CN = { satisfied: '满足', peckish: '还行', hungry: '有点饿', starving: '饿了' };
|
|
48
|
+
const MEAL_CN = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐' };
|
|
49
|
+
|
|
50
|
+
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
|
51
|
+
const tierOf = (v) => (v >= 80 ? 'satisfied' : v >= 60 ? 'peckish' : v >= 30 ? 'hungry' : 'starving');
|
|
52
|
+
const dayKey = (d) =>
|
|
53
|
+
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
54
|
+
|
|
55
|
+
function decayMs(fromMs, toMs) {
|
|
56
|
+
let total = 0;
|
|
57
|
+
let cur = fromMs;
|
|
58
|
+
while (cur < toMs) {
|
|
59
|
+
const d = new Date(cur);
|
|
60
|
+
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
61
|
+
const segStart = Math.max(cur, dayStart + FEED_DECAY_START_H * FEED_HOUR_MS);
|
|
62
|
+
const segEnd = Math.min(toMs, dayStart + 24 * FEED_HOUR_MS);
|
|
63
|
+
if (segEnd > segStart) total += segEnd - segStart;
|
|
64
|
+
cur = dayStart + 24 * FEED_HOUR_MS;
|
|
65
|
+
}
|
|
66
|
+
return total;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 读状态 → 算到此刻 → 返回结果(不落盘)。statePath 不存在时给默认值。 */
|
|
70
|
+
export function computeFeed(statePath, nowMs) {
|
|
71
|
+
let st = null;
|
|
72
|
+
try {
|
|
73
|
+
st = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
74
|
+
} catch {
|
|
75
|
+
st = null;
|
|
76
|
+
}
|
|
77
|
+
if (!st || typeof st.satiety !== 'number') st = { satiety: FEED_DEFAULT, updatedAt: nowMs, eaten: null };
|
|
78
|
+
const last = Number(st.updatedAt) || nowMs;
|
|
79
|
+
const dd = nowMs > last ? decayMs(last, nowMs) : 0;
|
|
80
|
+
let satiety = Math.max(0, Math.min(100, st.satiety - (dd / FEED_HOUR_MS) * FEED_DECAY_PER_HOUR));
|
|
81
|
+
const tierBefore = tierOf(satiety);
|
|
82
|
+
const now = new Date(nowMs);
|
|
83
|
+
const key = dayKey(now);
|
|
84
|
+
const eaten =
|
|
85
|
+
st.eaten && st.eaten.day === key
|
|
86
|
+
? { day: key, meals: { ...st.eaten.meals }, snacks: Number(st.eaten.snacks) || 0 }
|
|
87
|
+
: { day: key, meals: {}, snacks: 0 };
|
|
88
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
89
|
+
const meal = FEED_MEALS.find((m) => minutes >= m.from && minutes < m.to) || null;
|
|
90
|
+
let anim;
|
|
91
|
+
let line;
|
|
92
|
+
let add = 0;
|
|
93
|
+
let reason = 'ok';
|
|
94
|
+
if (meal) {
|
|
95
|
+
anim = meal.anim;
|
|
96
|
+
if (eaten.meals[meal.id]) {
|
|
97
|
+
line = pick(FEED_AGAIN_LINES);
|
|
98
|
+
reason = 'again';
|
|
99
|
+
} else {
|
|
100
|
+
add = meal.add;
|
|
101
|
+
line = pick(FEED_LINES[tierBefore]);
|
|
102
|
+
eaten.meals[meal.id] = true;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
anim = pick(FEED_SNACK_ANIMS);
|
|
106
|
+
if (eaten.snacks >= 2) {
|
|
107
|
+
line = pick(FEED_SNACK_LIMIT_LINES);
|
|
108
|
+
reason = 'snack-limit';
|
|
109
|
+
} else {
|
|
110
|
+
add = 8;
|
|
111
|
+
line = pick(FEED_LINES[tierBefore]);
|
|
112
|
+
eaten.snacks += 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
satiety = Math.max(0, Math.min(100, satiety + add));
|
|
116
|
+
return {
|
|
117
|
+
satiety,
|
|
118
|
+
tier: tierOf(satiety),
|
|
119
|
+
tierBefore,
|
|
120
|
+
meal: meal ? meal.id : 'snack',
|
|
121
|
+
anim,
|
|
122
|
+
line,
|
|
123
|
+
add,
|
|
124
|
+
reason,
|
|
125
|
+
eaten,
|
|
126
|
+
updatedAt: nowMs,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 状态文案:饱腹度 + 今天吃了什么。 */
|
|
131
|
+
export function statusText(r) {
|
|
132
|
+
const meals = (r.eaten && r.eaten.meals) || {};
|
|
133
|
+
const parts = Object.keys(meals).filter((k) => meals[k]).map((k) => MEAL_CN[k] ?? k);
|
|
134
|
+
if (r.eaten && r.eaten.snacks > 0) parts.push('零食×' + r.eaten.snacks);
|
|
135
|
+
return (
|
|
136
|
+
'饱腹度 ' + Math.round(r.satiety) + '%(' + (FEED_TIER_CN[r.tier] ?? '未知') + ')· 今天' +
|
|
137
|
+
(parts.length > 0 ? '吃了:' + parts.join('、') : '还没吃')
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ---------------- 与 dsh-pet 的 HTTP 通信 ---------------- */
|
|
142
|
+
|
|
143
|
+
function makeApi(baseUrl) {
|
|
144
|
+
const call = async (path, { method = 'GET', body, timeoutMs = 8000 } = {}) => {
|
|
145
|
+
const res = await fetch(baseUrl + path, {
|
|
146
|
+
method,
|
|
147
|
+
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
148
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
149
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
150
|
+
});
|
|
151
|
+
const text = await res.text();
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(text);
|
|
154
|
+
} catch {
|
|
155
|
+
return { ok: false, reason: 'bad-json', raw: text.slice(0, 200) };
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
state: () => call('/state'),
|
|
160
|
+
registerMenu: () => call('/menu/item', { method: 'POST', body: { owner: OWNER, labels: Object.values(LABELS), target: 'both' } }),
|
|
161
|
+
readEvents: (wait = 3000) => call(`/menu/events?owner=${encodeURIComponent(OWNER)}&wait=${wait}`),
|
|
162
|
+
lock: (pet, ttl = 20000) => call(`/lock?pet=${encodeURIComponent(pet)}&surface=any`, { method: 'POST', body: { ttl } }),
|
|
163
|
+
release: (pet, lockId) => call(`/lock/release?pet=${encodeURIComponent(pet)}&surface=any`, { method: 'POST', body: { lockId } }),
|
|
164
|
+
anim: (pet, lockId, name) => call(`/anim?pet=${encodeURIComponent(pet)}&surface=any&lock=${encodeURIComponent(lockId)}`, { method: 'POST', body: { name } }),
|
|
165
|
+
broadcast: (pet, lockId, payload) => call(`/broadcast?pet=${encodeURIComponent(pet)}&surface=any&lock=${encodeURIComponent(lockId)}`, { method: 'POST', body: payload }),
|
|
166
|
+
// 看屏走 /chat:图片进她的对话记忆,回复自动进气泡(不用再二次投喂)
|
|
167
|
+
chat: (pet, body) => call(`/chat?pet=${encodeURIComponent(pet)}&surface=any`, { method: 'POST', body, timeoutMs: 60000 }),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ---------------- 看屏:抓帧 + 触发策略 ---------------- */
|
|
172
|
+
|
|
173
|
+
const LOOK_DEFAULTS = {
|
|
174
|
+
mode: 'passive', // passive=只有点菜单才看;active=定时看
|
|
175
|
+
intervalSec: 90,
|
|
176
|
+
dailyBudget: 24,
|
|
177
|
+
prompt: '(她抬眼看了下屏幕)',
|
|
178
|
+
ffmpeg: 'ffmpeg',
|
|
179
|
+
width: 1600,
|
|
180
|
+
quality: 4,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function readLookConfig(configPath) {
|
|
184
|
+
let raw = {};
|
|
185
|
+
try {
|
|
186
|
+
raw = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
187
|
+
} catch {
|
|
188
|
+
raw = {};
|
|
189
|
+
}
|
|
190
|
+
return { ...LOOK_DEFAULTS, ...(raw.look || {}) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 首次运行时落一份默认配置,用户不用猜字段名;已存在就一个字不动。 */
|
|
194
|
+
export function ensureConfig(configPath) {
|
|
195
|
+
try {
|
|
196
|
+
if (readFileSync(configPath, 'utf8')) return { created: false };
|
|
197
|
+
} catch (e) {}
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
200
|
+
writeFileSync(configPath, JSON.stringify({ look: LOOK_DEFAULTS }, null, 2) + '\n', 'utf8');
|
|
201
|
+
return { created: true };
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return { created: false, error: String((e && e.message) || e) };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 校验并合并 look 配置(设置页与 HTTP 共用同一份规则)。
|
|
209
|
+
* 只接受白名单字段;数值限界;不传的字段原样保留。
|
|
210
|
+
*/
|
|
211
|
+
const LOOK_LIMITS = { intervalSec: [15, 3600], dailyBudget: [0, 500], width: [320, 3840], quality: [1, 10] };
|
|
212
|
+
|
|
213
|
+
export function sanitizeLook(patch) {
|
|
214
|
+
if (!patch || typeof patch !== 'object') return { error: 'bad-body' };
|
|
215
|
+
const out = {};
|
|
216
|
+
if (patch.mode !== undefined) {
|
|
217
|
+
if (patch.mode !== 'passive' && patch.mode !== 'active') return { error: 'mode' };
|
|
218
|
+
out.mode = patch.mode;
|
|
219
|
+
}
|
|
220
|
+
for (const [k, [min, max]] of Object.entries(LOOK_LIMITS)) {
|
|
221
|
+
if (patch[k] === undefined) continue;
|
|
222
|
+
const n = Number(patch[k]);
|
|
223
|
+
if (!Number.isFinite(n) || n < min || n > max) return { error: k };
|
|
224
|
+
out[k] = Math.round(n);
|
|
225
|
+
}
|
|
226
|
+
if (patch.prompt !== undefined) {
|
|
227
|
+
const s = String(patch.prompt).trim();
|
|
228
|
+
if (!s || s.length > 100) return { error: 'prompt' };
|
|
229
|
+
out.prompt = s;
|
|
230
|
+
}
|
|
231
|
+
if (patch.ffmpeg !== undefined) {
|
|
232
|
+
const s = String(patch.ffmpeg).trim();
|
|
233
|
+
if (!s || s.length > 260) return { error: 'ffmpeg' };
|
|
234
|
+
out.ffmpeg = s;
|
|
235
|
+
}
|
|
236
|
+
return { ok: out };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 把校验过的 look 补丁写进配置(保留其它字段),返回合并后的 look。 */
|
|
240
|
+
export function writeLookConfig(configPath, patch) {
|
|
241
|
+
let raw = {};
|
|
242
|
+
try {
|
|
243
|
+
raw = JSON.parse(readFileSync(configPath, 'utf8')) || {};
|
|
244
|
+
} catch (e) {
|
|
245
|
+
raw = {};
|
|
246
|
+
}
|
|
247
|
+
const merged = { ...raw, look: { ...(raw.look || {}), ...patch } };
|
|
248
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
249
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
250
|
+
return merged.look;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 设置页:一个自包含的 HTML,不依赖任何前端资源。 */
|
|
254
|
+
export const SETTINGS_HTML = `<!doctype html>
|
|
255
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>pet-kit 设置</title>
|
|
256
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
257
|
+
<style>
|
|
258
|
+
body{font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;background:#0f1115;color:#e6e6e6;margin:0;padding:32px}
|
|
259
|
+
main{max-width:640px;margin:0 auto}
|
|
260
|
+
h1{font-size:18px;margin:0 0 4px} p.sub{color:#8b93a1;margin:0 0 24px}
|
|
261
|
+
fieldset{border:1px solid #262a33;border-radius:10px;padding:16px 18px;margin:0 0 16px}
|
|
262
|
+
legend{color:#9aa4b2;padding:0 6px}
|
|
263
|
+
label{display:block;margin:12px 0 6px;color:#c9d1db}
|
|
264
|
+
input[type=text],input[type=number]{width:100%;box-sizing:border-box;background:#161a21;border:1px solid #2b313b;
|
|
265
|
+
border-radius:8px;color:#e6e6e6;padding:9px 10px;font:inherit}
|
|
266
|
+
.row{display:flex;gap:16px}.row>div{flex:1}
|
|
267
|
+
.hint{color:#77808d;font-size:12px;margin-top:4px}
|
|
268
|
+
.modes{display:flex;gap:10px;margin-top:6px}
|
|
269
|
+
.modes label{display:flex;align-items:center;gap:8px;flex:1;margin:0;padding:10px 12px;border:1px solid #2b313b;
|
|
270
|
+
border-radius:8px;cursor:pointer;background:#161a21}
|
|
271
|
+
.modes input{margin:0}
|
|
272
|
+
button{background:#2f6feb;color:#fff;border:0;border-radius:8px;padding:10px 18px;font:inherit;cursor:pointer}
|
|
273
|
+
button:disabled{opacity:.5;cursor:default}
|
|
274
|
+
output{display:block;margin-top:14px;color:#6fb37a;min-height:20px}
|
|
275
|
+
output.err{color:#e0645f}
|
|
276
|
+
</style></head><body><main>
|
|
277
|
+
<h1>pet-kit 设置</h1>
|
|
278
|
+
<p class="sub">看屏(「看眼屏幕」)的行为。保存后立刻生效,不用重启。</p>
|
|
279
|
+
<form id="f">
|
|
280
|
+
<fieldset><legend>什么时候看</legend>
|
|
281
|
+
<div class="modes">
|
|
282
|
+
<label><input type="radio" name="mode" value="passive"> 只在我点的时候看</label>
|
|
283
|
+
<label><input type="radio" name="mode" value="active"> 定时自己看</label>
|
|
284
|
+
</div>
|
|
285
|
+
<p class="hint">定时模式每次看都会出声(她念一句),并算一次模型调用。</p>
|
|
286
|
+
<div class="row">
|
|
287
|
+
<div><label>最小间隔(秒)</label><input type="number" name="intervalSec" min="15" max="3600" step="5"></div>
|
|
288
|
+
<div><label>每天最多(次)</label><input type="number" name="dailyBudget" min="0" max="500" step="1"></div>
|
|
289
|
+
</div>
|
|
290
|
+
</fieldset>
|
|
291
|
+
<fieldset><legend>抓屏</legend>
|
|
292
|
+
<label>ffmpeg 路径</label><input type="text" name="ffmpeg" placeholder="ffmpeg">
|
|
293
|
+
<p class="hint">留空或写 ffmpeg = 从 PATH 找。没有 ffmpeg 就看不了屏。</p>
|
|
294
|
+
<div class="row">
|
|
295
|
+
<div><label>抓帧宽度</label><input type="number" name="width" min="320" max="3840" step="80"></div>
|
|
296
|
+
<div><label>JPEG 质量(1 最好,10 最小)</label><input type="number" name="quality" min="1" max="10" step="1"></div>
|
|
297
|
+
</div>
|
|
298
|
+
</fieldset>
|
|
299
|
+
<fieldset><legend>她对屏幕说的那句话</legend>
|
|
300
|
+
<label>提示词</label><input type="text" name="prompt" maxlength="100">
|
|
301
|
+
<p class="hint">这句会作为你对她说的那一句进对话记忆,她的回复自动进气泡。</p>
|
|
302
|
+
</fieldset>
|
|
303
|
+
<button type="submit" id="save">保存</button>
|
|
304
|
+
<output id="msg"></output>
|
|
305
|
+
</form>
|
|
306
|
+
</main><script>
|
|
307
|
+
const $ = (n) => document.querySelector('[name="' + n + '"]');
|
|
308
|
+
const msg = document.getElementById('msg');
|
|
309
|
+
const all = ['mode','intervalSec','dailyBudget','ffmpeg','width','quality','prompt'];
|
|
310
|
+
async function load(){
|
|
311
|
+
const r = await fetch('/pet-kit/config').then(r=>r.json());
|
|
312
|
+
const c = r.config || {};
|
|
313
|
+
for (const k of all){
|
|
314
|
+
if (k === 'mode'){ document.querySelectorAll('[name=mode]').forEach(el=>el.checked = el.value === c.mode); }
|
|
315
|
+
else if (k in c) $(k).value = c[k];
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
document.getElementById('f').addEventListener('submit', async (e) => {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
const body = {};
|
|
321
|
+
body.mode = document.querySelector('[name=mode]:checked')?.value;
|
|
322
|
+
for (const k of ['intervalSec','dailyBudget','width','quality']) body[k] = Number($(k).value);
|
|
323
|
+
body.ffmpeg = $('ffmpeg').value.trim();
|
|
324
|
+
body.prompt = $('prompt').value.trim();
|
|
325
|
+
const btn = document.getElementById('save'); btn.disabled = true;
|
|
326
|
+
msg.className = ''; msg.textContent = '保存中…';
|
|
327
|
+
try {
|
|
328
|
+
const r = await fetch('/pet-kit/config', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
|
|
329
|
+
const j = await r.json();
|
|
330
|
+
if (j.ok) { msg.textContent = '已保存:' + j.config.mode + ',间隔 ' + j.config.intervalSec + ' 秒'; }
|
|
331
|
+
else { msg.className = 'err'; msg.textContent = '没保存成功:' + (j.reason || '未知错误') + (j.field ? '(' + j.field + ')' : ''); }
|
|
332
|
+
} catch (err) { msg.className = 'err'; msg.textContent = '没保存成功:' + err.message; }
|
|
333
|
+
btn.disabled = false;
|
|
334
|
+
});
|
|
335
|
+
load();
|
|
336
|
+
</script></body></html>`;
|
|
337
|
+
|
|
338
|
+
/** 用系统默认浏览器打开一个本地地址(设置页用)。 */
|
|
339
|
+
export function openInBrowser(url) {
|
|
340
|
+
try {
|
|
341
|
+
if (process.platform === 'win32') execFile('cmd', ['/c', 'start', '', url]);
|
|
342
|
+
else if (process.platform === 'darwin') execFile('open', [url]);
|
|
343
|
+
else execFile('xdg-open', [url]);
|
|
344
|
+
return true;
|
|
345
|
+
} catch (e) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 抓一帧屏幕(JPEG 字节,不落盘)。
|
|
352
|
+
* 用 ffmpeg 的 ddagrab(DXGI Desktop Duplication):和 Electron 的 desktopCapturer 一样是
|
|
353
|
+
* **合成后的桌面**,所以视频/硬件加速的画面不会像 GDI 那样截成白屏。
|
|
354
|
+
* 游戏要开无边窗(独占全屏不进合成,谁抓都是黑的)。
|
|
355
|
+
*/
|
|
356
|
+
function grabScreen({ ffmpeg, width, quality }) {
|
|
357
|
+
return new Promise((resolve, reject) => {
|
|
358
|
+
const args = [
|
|
359
|
+
'-hide_banner', '-loglevel', 'error',
|
|
360
|
+
'-f', 'lavfi', '-i', 'ddagrab=output_idx=0',
|
|
361
|
+
'-vf', `hwdownload,format=bgra,scale=${width}:-2`,
|
|
362
|
+
'-frames:v', '1', '-f', 'mjpeg', '-q:v', String(quality), 'pipe:1',
|
|
363
|
+
];
|
|
364
|
+
execFile(ffmpeg, args, { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024, timeout: 15000 }, (err, stdout) => {
|
|
365
|
+
if (err) reject(err);
|
|
366
|
+
else if (!stdout || stdout.length < 1000) reject(new Error('ffmpeg 没有吐出有效帧'));
|
|
367
|
+
else resolve(Buffer.from(stdout));
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 造一个 pet-kit 实例(不含菜单轮询,方便单测)。
|
|
374
|
+
* @param {{ baseUrl: string, statePath: string, log?: (s: string) => void }} opts
|
|
375
|
+
*/
|
|
376
|
+
export function createPetKit({ baseUrl, statePath, configPath = join(dirname(statePath), 'config.json'), log = () => {} }) {
|
|
377
|
+
const api = makeApi(baseUrl);
|
|
378
|
+
const statPath = join(dirname(configPath), 'look-stat.json');
|
|
379
|
+
let lastFrameHash = '';
|
|
380
|
+
|
|
381
|
+
/** 一次完整的「说一句 + 演一次」:锁 → 动画 → 气泡 → 放锁。 */
|
|
382
|
+
async function perform(petId, { text, anim, duration }) {
|
|
383
|
+
const l = await api.lock(petId);
|
|
384
|
+
if (!l || l.ok !== true || !l.lockId) return { ok: false, reason: l?.reason || 'lock-failed' };
|
|
385
|
+
try {
|
|
386
|
+
let animError = null;
|
|
387
|
+
if (anim) {
|
|
388
|
+
const a = await api.anim(petId, l.lockId, anim);
|
|
389
|
+
if (!a || a.ok !== true) animError = a?.reason || 'anim-failed';
|
|
390
|
+
}
|
|
391
|
+
const b = await api.broadcast(petId, l.lockId, { text, duration, anim: false });
|
|
392
|
+
if (!b || b.ok !== true) return { ok: false, reason: b?.reason || 'broadcast-failed', animError };
|
|
393
|
+
return { ok: true, animError };
|
|
394
|
+
} finally {
|
|
395
|
+
try {
|
|
396
|
+
await api.release(petId, l.lockId);
|
|
397
|
+
} catch (e) {}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function feed(petId = 'main') {
|
|
402
|
+
const r = computeFeed(statePath, Date.now());
|
|
403
|
+
// 先记账,后说话:饱腹度是数据真相,气泡失败只影响体验。
|
|
404
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
405
|
+
writeFileSync(statePath, JSON.stringify({ satiety: r.satiety, updatedAt: r.updatedAt, eaten: r.eaten }, null, 2), 'utf8');
|
|
406
|
+
const out = await perform(petId, { text: r.line, anim: r.anim, duration: 6000 });
|
|
407
|
+
log(`feed pet=${petId} satiety=${r.satiety.toFixed(1)} reason=${r.reason} spoken=${out.ok}${out.animError ? ' anim=' + out.animError : ''}`);
|
|
408
|
+
return { ok: out.ok, satiety: Math.round(r.satiety * 10) / 10, tier: r.tier, line: r.line, anim: r.anim, reason: r.reason, spoken: out.ok, animError: out.animError || null };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function status(petId = 'main') {
|
|
412
|
+
const r = computeFeed(statePath, Date.now());
|
|
413
|
+
const text = statusText(r);
|
|
414
|
+
const out = await perform(petId, { text, anim: null, duration: 8000 });
|
|
415
|
+
log(`status pet=${petId} satiety=${r.satiety.toFixed(1)}`);
|
|
416
|
+
return { ok: out.ok, satiety: Math.round(r.satiety * 10) / 10, tier: r.tier, text, spoken: out.ok };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 看一眼前屏,让她说一句(走 /chat:进记忆,回复自动进气泡)。
|
|
421
|
+
* force=true 是手动点击:绕过模式、去重和每日预算(用户明确要看)。
|
|
422
|
+
*/
|
|
423
|
+
async function look(petId = 'main', { force = false } = {}) {
|
|
424
|
+
const cfg = readLookConfig(configPath);
|
|
425
|
+
if (!force && cfg.mode !== 'active') return { ok: false, reason: 'passive-mode' };
|
|
426
|
+
|
|
427
|
+
// 每日预算(手动点击不计)
|
|
428
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
429
|
+
let stat = { day: today, count: 0 };
|
|
430
|
+
try {
|
|
431
|
+
const s = JSON.parse(readFileSync(statPath, 'utf8'));
|
|
432
|
+
if (s && s.day === today) stat = { day: today, count: Number(s.count) || 0 };
|
|
433
|
+
} catch (e) {}
|
|
434
|
+
if (!force && stat.count >= Number(cfg.dailyBudget || 0)) return { ok: false, reason: 'daily-budget' };
|
|
435
|
+
|
|
436
|
+
let buf;
|
|
437
|
+
try {
|
|
438
|
+
buf = await grabScreen(cfg);
|
|
439
|
+
} catch (e) {
|
|
440
|
+
const msg = String((e && e.message) || e).slice(0, 160);
|
|
441
|
+
log(`look 抓帧失败:${msg}`);
|
|
442
|
+
return { ok: false, reason: 'capture-failed', message: msg };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const hash = createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
|
446
|
+
if (!force && hash === lastFrameHash) return { ok: false, reason: 'same-frame' };
|
|
447
|
+
lastFrameHash = hash;
|
|
448
|
+
|
|
449
|
+
stat = { day: today, count: stat.count + 1 };
|
|
450
|
+
try {
|
|
451
|
+
mkdirSync(dirname(statPath), { recursive: true });
|
|
452
|
+
writeFileSync(statPath, JSON.stringify(stat), 'utf8');
|
|
453
|
+
} catch (e) {}
|
|
454
|
+
|
|
455
|
+
const res = await api.chat(petId, {
|
|
456
|
+
text: cfg.prompt,
|
|
457
|
+
attachment: { mediaType: 'image/jpeg', data: buf.toString('base64') },
|
|
458
|
+
});
|
|
459
|
+
const ok = !!(res && res.ok === true);
|
|
460
|
+
log(`look pet=${petId} bytes=${buf.length} hash=${hash} ok=${ok}${ok ? ' text=' + String(res.text || '').slice(0, 24) : ' reason=' + (res && res.reason)}`);
|
|
461
|
+
return ok
|
|
462
|
+
? { ok: true, bytes: buf.length, text: res.text, image: res.image }
|
|
463
|
+
: { ok: false, reason: (res && res.reason) || 'chat-failed', message: res && res.message, bytes: buf.length };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { feed, status, look, registerMenu: api.registerMenu, readEvents: api.readEvents, api };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 数据路径:<DSH_HOME>/pet-kit/feed-state.json,首次从 dsh-pet 目录续上。 */
|
|
470
|
+
export function resolvePaths(dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')) {
|
|
471
|
+
const statePath = join(dshHome, 'pet-kit', 'feed-state.json');
|
|
472
|
+
const legacy = join(dshHome, 'dsh-pet', 'feed-state.json');
|
|
473
|
+
if (!readFileSafe(statePath) && readFileSafe(legacy)) {
|
|
474
|
+
try {
|
|
475
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
476
|
+
writeFileSync(statePath, readFileSafe(legacy), 'utf8');
|
|
477
|
+
} catch (e) {}
|
|
478
|
+
}
|
|
479
|
+
return { statePath, legacy };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function readFileSafe(p) {
|
|
483
|
+
try {
|
|
484
|
+
return readFileSync(p, 'utf8');
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* ---------------- Cordis 插件外壳 ---------------- */
|
|
491
|
+
|
|
492
|
+
export default {
|
|
493
|
+
name: OWNER,
|
|
494
|
+
apply(root) {
|
|
495
|
+
root.inject(['webServer'], (ctx) => {
|
|
496
|
+
const port = ctx.webServer?.port;
|
|
497
|
+
if (!port) {
|
|
498
|
+
console.warn('[pet-kit] 拿不到 webServer 端口,投喂/状态不可用');
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const baseUrl = `http://127.0.0.1:${port}/dsh-pet-7340`;
|
|
502
|
+
const { statePath } = resolvePaths();
|
|
503
|
+
const configPath = join(dirname(statePath), 'config.json');
|
|
504
|
+
ensureConfig(configPath);
|
|
505
|
+
const kit = createPetKit({ baseUrl, statePath, configPath, log: (s) => console.log('[pet-kit] ' + s) });
|
|
506
|
+
|
|
507
|
+
let stopped = false;
|
|
508
|
+
let busy = false;
|
|
509
|
+
let healthy = false;
|
|
510
|
+
let healthDetail = '还没检查';
|
|
511
|
+
let pollStarted = false;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* 健康检查:不只比版本号,直接打两枪——`/state` 的形状对不对、菜单能不能注册。
|
|
515
|
+
* 版本号在 `/config/meta` 里拿不到(只有 user/default/…/profile),而"接口行为对不对"
|
|
516
|
+
* 本来就是我们唯一真正依赖的东西。
|
|
517
|
+
*/
|
|
518
|
+
const checkHealth = async () => {
|
|
519
|
+
try {
|
|
520
|
+
const st = await kit.api.state();
|
|
521
|
+
if (!st || typeof st.counter !== 'number' || typeof st.pets !== 'object') throw new Error('/state 形状不对');
|
|
522
|
+
const m = await kit.registerMenu();
|
|
523
|
+
if (!m || m.ok !== true) throw new Error('menu/item 注册失败:' + ((m && m.reason) || '未知'));
|
|
524
|
+
healthy = true;
|
|
525
|
+
healthDetail = 'ok';
|
|
526
|
+
return true;
|
|
527
|
+
} catch (e) {
|
|
528
|
+
healthy = false;
|
|
529
|
+
healthDetail = String((e && e.message) || e).slice(0, 160);
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// 点击轮询:长轮询 3 秒,读取即消费。
|
|
535
|
+
const poll = async () => {
|
|
536
|
+
if (stopped) return;
|
|
537
|
+
try {
|
|
538
|
+
const r = await kit.readEvents(3000);
|
|
539
|
+
const events = (r && r.ok && r.events) || {};
|
|
540
|
+
for (const [label, petIdOrNull] of Object.entries(events)) {
|
|
541
|
+
// 事件表里每个 label 常驻,值是 null = 这次没点它。只有非空字符串才是真的点击。
|
|
542
|
+
if (typeof petIdOrNull !== 'string' || petIdOrNull === '') continue;
|
|
543
|
+
const petId = petIdOrNull;
|
|
544
|
+
if (busy) continue;
|
|
545
|
+
busy = true;
|
|
546
|
+
try {
|
|
547
|
+
if (label === LABELS.feed) await kit.feed(petId);
|
|
548
|
+
else if (label === LABELS.status) await kit.status(petId);
|
|
549
|
+
else if (label === LABELS.look) await kit.look(petId, { force: true });
|
|
550
|
+
else if (label === LABELS.settings) openInBrowser(`http://127.0.0.1:${port}/pet-kit/settings`);
|
|
551
|
+
} finally {
|
|
552
|
+
busy = false;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} catch (e) {
|
|
556
|
+
await new Promise((res) => setTimeout(res, 2000));
|
|
557
|
+
}
|
|
558
|
+
if (!stopped) setTimeout(poll, 50);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// 健康检查通过才开始轮询;dsh-pet 可能比我们晚就绪,所以带退避重试。
|
|
562
|
+
const boot = async (attempt = 0) => {
|
|
563
|
+
if (stopped) return;
|
|
564
|
+
if (await checkHealth()) {
|
|
565
|
+
console.log('[pet-kit] 健康检查通过,菜单已注册(投喂 / 状态 / 看眼屏幕)');
|
|
566
|
+
if (!pollStarted) {
|
|
567
|
+
pollStarted = true;
|
|
568
|
+
poll();
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (attempt < 30) setTimeout(() => boot(attempt + 1), Math.min(2000 + attempt * 1000, 10000));
|
|
573
|
+
else console.warn('[pet-kit] 健康检查连续失败,已禁用:' + healthDetail);
|
|
574
|
+
};
|
|
575
|
+
boot();
|
|
576
|
+
|
|
577
|
+
// 主动看屏:每 15 秒醒一次,读配置决定要不要看。默认 passive ⇒ 这里什么都不做。
|
|
578
|
+
let lastLookAt = 0;
|
|
579
|
+
const tick = setInterval(() => {
|
|
580
|
+
if (stopped || busy || !healthy) return;
|
|
581
|
+
const cfg = readLookConfig(configPath);
|
|
582
|
+
if (cfg.mode !== 'active') return;
|
|
583
|
+
if (Date.now() - lastLookAt < Number(cfg.intervalSec || 90) * 1000) return;
|
|
584
|
+
lastLookAt = Date.now();
|
|
585
|
+
busy = true;
|
|
586
|
+
kit
|
|
587
|
+
.look('main')
|
|
588
|
+
.catch(() => {})
|
|
589
|
+
.finally(() => {
|
|
590
|
+
busy = false;
|
|
591
|
+
});
|
|
592
|
+
}, 15000);
|
|
593
|
+
tick.unref?.();
|
|
594
|
+
|
|
595
|
+
// HTTP 入口:给 CLI / 别的插件用,菜单之外的第二条路。
|
|
596
|
+
const ROUTE = '/pet-kit';
|
|
597
|
+
const send = (res, code, obj) => {
|
|
598
|
+
if (res.writableEnded) return;
|
|
599
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
600
|
+
res.end(JSON.stringify(obj));
|
|
601
|
+
};
|
|
602
|
+
const onlyLoopback = (req) =>
|
|
603
|
+
['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(String((req.socket && req.socket.remoteAddress) || ''));
|
|
604
|
+
const sendHtml = (res, html) => {
|
|
605
|
+
if (res.writableEnded) return;
|
|
606
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
607
|
+
res.end(html);
|
|
608
|
+
};
|
|
609
|
+
const readBody = (req) =>
|
|
610
|
+
new Promise((resolve) => {
|
|
611
|
+
let s = '';
|
|
612
|
+
req.on('data', (c) => {
|
|
613
|
+
s += c;
|
|
614
|
+
if (s.length > 64 * 1024) req.destroy();
|
|
615
|
+
});
|
|
616
|
+
req.on('end', () => resolve(s));
|
|
617
|
+
});
|
|
618
|
+
const settingsUrl = `http://127.0.0.1:${port}${ROUTE}/settings`;
|
|
619
|
+
const handler = async (req, res) => {
|
|
620
|
+
try {
|
|
621
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
622
|
+
const rest = decodeURIComponent(url.pathname.slice(ROUTE.length + 1)).replace(/\/+$/, '');
|
|
623
|
+
const pet = url.searchParams.get('pet') || 'main';
|
|
624
|
+
if (!onlyLoopback(req)) return send(res, 403, { ok: false, reason: 'loopback-only' });
|
|
625
|
+
if (rest === 'health' && req.method === 'GET') {
|
|
626
|
+
return send(res, 200, { ok: true, healthy, detail: healthDetail, upstream: baseUrl, settings: settingsUrl });
|
|
627
|
+
}
|
|
628
|
+
// 设置页与配置读写:pet 挂了也要能用,所以放在 healthy 判断之前。
|
|
629
|
+
if (rest === 'settings' && req.method === 'GET') return sendHtml(res, SETTINGS_HTML);
|
|
630
|
+
if (rest === 'config' && req.method === 'GET') {
|
|
631
|
+
return send(res, 200, { ok: true, config: readLookConfig(configPath) });
|
|
632
|
+
}
|
|
633
|
+
if (rest === 'config' && req.method === 'POST') {
|
|
634
|
+
const raw = await readBody(req);
|
|
635
|
+
let body;
|
|
636
|
+
try {
|
|
637
|
+
body = JSON.parse(raw || '{}');
|
|
638
|
+
} catch (e) {
|
|
639
|
+
return send(res, 400, { ok: false, reason: 'bad-json' });
|
|
640
|
+
}
|
|
641
|
+
const v = sanitizeLook(body);
|
|
642
|
+
if (v.error) return send(res, 400, { ok: false, reason: 'invalid-field', field: v.error });
|
|
643
|
+
const saved = writeLookConfig(configPath, v.ok);
|
|
644
|
+
return send(res, 200, { ok: true, config: { ...readLookConfig(configPath), ...saved } });
|
|
645
|
+
}
|
|
646
|
+
if (req.method !== 'POST') return send(res, 405, { ok: false, reason: 'method-not-allowed' });
|
|
647
|
+
if (!healthy) return send(res, 503, { ok: false, reason: 'not-ready', detail: healthDetail });
|
|
648
|
+
if (rest === 'feed') return send(res, 200, await kit.feed(pet));
|
|
649
|
+
if (rest === 'status') return send(res, 200, await kit.status(pet));
|
|
650
|
+
if (rest === 'look') return send(res, 200, await kit.look(pet, { force: true }));
|
|
651
|
+
return send(res, 404, { ok: false, reason: 'unknown-route' });
|
|
652
|
+
} catch (e) {
|
|
653
|
+
return send(res, 500, { ok: false, reason: 'error', message: String((e && e.message) || e) });
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
const disposeRoute = ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler });
|
|
657
|
+
|
|
658
|
+
ctx.effect(() => () => {
|
|
659
|
+
stopped = true;
|
|
660
|
+
clearInterval(tick);
|
|
661
|
+
try {
|
|
662
|
+
disposeRoute?.();
|
|
663
|
+
} catch (e) {}
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
},
|
|
667
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pet-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "给 dsh-pet 补上投喂、状态和看眼屏幕:只走上游公开 HTTP 接口,不改它一行代码",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "AyAy1314_",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Yuhuan-zz/pet-kit.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Yuhuan-zz/pet-kit#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Yuhuan-zz/pet-kit/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"dsh",
|
|
25
|
+
"dsh-plugin",
|
|
26
|
+
"deepseek-harness",
|
|
27
|
+
"dsh-pet",
|
|
28
|
+
"desktop-pet"
|
|
29
|
+
],
|
|
30
|
+
"dsh": {
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"dsh-pet": ">=0.4.8"
|
|
40
|
+
}
|
|
6
41
|
}
|