dsh-pet 0.1.6 → 0.1.7
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/README.en.md +116 -100
- package/README.md +116 -101
- package/cordis.patch.yml +0 -9
- package/lib/client.js +836 -625
- package/lib/index.js +27 -17
- package/lib/types/index.d.ts +2 -12
- package/package.json +12 -3
- package/src/client/app.ts +13 -451
- package/src/client/config.ts +81 -105
- package/src/client/index.ts +1 -0
- package/src/client/motion.ts +36 -0
- package/src/client/pet.ts +482 -0
- package/src/client/pickers.ts +31 -4
- package/src/client/settings.ts +441 -133
- package/src/client/types.ts +30 -10
- package/src/host/index.ts +126 -99
package/src/client/types.ts
CHANGED
|
@@ -1,37 +1,57 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 与 config.jsonc 结构完全同构的类型模型(唯一事实来源 = config.jsonc 的
|
|
2
|
+
// animations / animationWeights / pets)。运行时(ANIM / 设置页 / PetCard)
|
|
3
|
+
// 直接使用这套结构,不额外造转换后的类型。
|
|
4
|
+
|
|
5
|
+
/** 支持的角落 */
|
|
6
|
+
export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
7
|
+
|
|
2
8
|
/** 移动动作:一个动作名 + 可选覆盖参数(未写字段取 moves.default) */
|
|
3
9
|
export interface MoveSpec {
|
|
4
10
|
name: string;
|
|
5
11
|
params?: Record<string, number>;
|
|
6
12
|
}
|
|
7
|
-
|
|
13
|
+
|
|
14
|
+
/** 移动池 */
|
|
8
15
|
export interface MovesConfig {
|
|
9
16
|
default: Record<string, number>;
|
|
10
17
|
actions: MoveSpec[];
|
|
11
18
|
}
|
|
12
|
-
|
|
19
|
+
|
|
20
|
+
/** 随机动作分类(带文字、镜像会颠倒,facing=right 时跳过) */
|
|
13
21
|
export interface Category {
|
|
14
22
|
id: string;
|
|
15
23
|
weight: number;
|
|
16
|
-
/** 带文字、镜像会颠倒:facing=right 时跳过 */
|
|
17
24
|
noMirror?: boolean;
|
|
18
25
|
actions: string[];
|
|
19
26
|
}
|
|
20
|
-
|
|
27
|
+
|
|
28
|
+
/** 动画权重 */
|
|
21
29
|
export interface Weights {
|
|
22
30
|
idle: number;
|
|
23
31
|
turn: number;
|
|
24
32
|
move: number;
|
|
25
33
|
}
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
|
|
35
|
+
/** config.jsonc 的 animations 段 */
|
|
36
|
+
export interface Animations {
|
|
28
37
|
idle: string[];
|
|
29
38
|
turn: string[];
|
|
30
39
|
drag: string[];
|
|
31
40
|
clicks: string[];
|
|
32
41
|
moves: MovesConfig;
|
|
33
42
|
categories: Category[];
|
|
34
|
-
weights: Weights;
|
|
35
43
|
}
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
|
|
45
|
+
/** 一只宠物(与 jsonc pets[i] 同形,position 嵌套) */
|
|
46
|
+
export interface Pet {
|
|
47
|
+
id: string;
|
|
48
|
+
size: number;
|
|
49
|
+
position: { corner: Corner; marginX: number; marginY: number };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** config.jsonc 全集——运行时直接使用(ANIM 即本类型) */
|
|
53
|
+
export interface ClientConfig {
|
|
54
|
+
pets: Pet[];
|
|
55
|
+
animations: Animations;
|
|
56
|
+
animationWeights: Weights;
|
|
57
|
+
}
|
package/src/host/index.ts
CHANGED
|
@@ -5,19 +5,20 @@
|
|
|
5
5
|
* 流式返回给浏览器。源文件(src/host/index.ts)由 tsdown 构建为 lib/index.js。
|
|
6
6
|
*
|
|
7
7
|
* 路由:
|
|
8
|
-
* /pet/thumb/<动画名>.webm → 插件包内 assets/thumb/
|
|
9
|
-
* /pet/full/<动画名>.webm → $DSH_HOME/pet-assets/(原始母版,需手动下载)
|
|
8
|
+
* /pet/thumb/<动画名>.webm → $DSH_HOME/dsh-pet/main-animation/(用户目录,优先)→ 插件包内 assets/thumb/
|
|
10
9
|
* /pet/config.jsonc → 插件包内 assets/config.jsonc(默认值,只读)
|
|
11
|
-
* /pet/config →
|
|
10
|
+
* /pet/config → 用户覆盖配置(pets / animations / animationWeights,JSON)
|
|
12
11
|
* GET 读取、PUT 保存、DELETE 恢复默认(删除用户层)
|
|
12
|
+
* /pet/config/meta → 配置文件与素材目录路径(设置页展示用)
|
|
13
13
|
*
|
|
14
|
-
* 安全性:resolveAsset 做"防穿越"
|
|
14
|
+
* 安全性:resolveAsset 做"防穿越"校验,保证路径仍在对应根目录内。
|
|
15
15
|
*
|
|
16
|
-
* TODO(类型):peer 依赖类型包本地暂不可解析,ctx/
|
|
16
|
+
* TODO(类型):peer 依赖类型包本地暂不可解析,ctx/req/res 暂用 any;
|
|
17
17
|
* 依赖可解析后替换为 DSH 官方类型。
|
|
18
18
|
*/
|
|
19
19
|
import { createReadStream, existsSync } from 'node:fs';
|
|
20
|
-
import { readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { readFile, mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
21
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
21
22
|
import { join, normalize, resolve, sep } from 'node:path';
|
|
22
23
|
import { fileURLToPath } from 'node:url';
|
|
23
24
|
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
@@ -54,8 +55,14 @@ function resolveAsset(root: string, rel: string): string | undefined {
|
|
|
54
55
|
return candidate;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** 在 root 下解析并确认实体存在;非法(穿越)或不存在时返回 undefined */
|
|
59
|
+
function resolveExisting(root: string, rel: string): string | undefined {
|
|
60
|
+
const candidate = resolveAsset(root, rel);
|
|
61
|
+
return candidate && existsSync(candidate) ? candidate : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
/** 流式返回一个文件(带 Content-Type / 长度 / 缓存头)。 */
|
|
58
|
-
async function sendFile(res:
|
|
65
|
+
async function sendFile(res: ServerResponse, file: string, contentType: string): Promise<void> {
|
|
59
66
|
const { size } = await stat(file);
|
|
60
67
|
res.writeHead(200, {
|
|
61
68
|
'content-type': contentType,
|
|
@@ -71,14 +78,17 @@ async function sendFile(res: any, file: string, contentType: string): Promise<vo
|
|
|
71
78
|
const CORNERS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
72
79
|
|
|
73
80
|
/** 发送 JSON 响应 */
|
|
74
|
-
function sendJson(res:
|
|
81
|
+
function sendJson(res: ServerResponse, status: number, obj: unknown): void {
|
|
75
82
|
const body = JSON.stringify(obj);
|
|
76
|
-
res.writeHead(status, {
|
|
83
|
+
res.writeHead(status, {
|
|
84
|
+
'content-type': 'application/json; charset=utf-8',
|
|
85
|
+
'content-length': Buffer.byteLength(body),
|
|
86
|
+
});
|
|
77
87
|
res.end(body);
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
/** 收集请求体(文本) */
|
|
81
|
-
function readBody(req:
|
|
91
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
82
92
|
return new Promise((resolve2, reject) => {
|
|
83
93
|
const chunks: Buffer[] = [];
|
|
84
94
|
req.on('data', (c: Buffer) => chunks.push(c));
|
|
@@ -89,17 +99,20 @@ function readBody(req: any): Promise<string> {
|
|
|
89
99
|
|
|
90
100
|
/** 校验并归一化用户配置:只接受 { pets: [{ id, size, position }] } */
|
|
91
101
|
function sanitizeUserConfig(raw: unknown): { pets: unknown[] } | null {
|
|
92
|
-
const o = raw && typeof raw === 'object' ? raw as Record<string,
|
|
102
|
+
const o = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
93
103
|
const arr = Array.isArray(o.pets) ? o.pets : null;
|
|
94
104
|
if (!arr || !arr.length) return null;
|
|
95
105
|
const out: unknown[] = [];
|
|
96
106
|
for (const p of arr) {
|
|
97
107
|
if (!p || typeof p !== 'object') return null;
|
|
98
|
-
const
|
|
108
|
+
const pp = p as Record<string, unknown>;
|
|
109
|
+
const id = String(pp.id ?? '');
|
|
110
|
+
// 有意过滤文件名非法字符(Windows 保留符 + 控制字符),防止配置值逃逸 main-config.json 路径
|
|
111
|
+
// eslint-disable-next-line no-control-regex
|
|
99
112
|
if (!id || id.length > 64 || /[\\/:\x00-\x1f]/.test(id)) return null;
|
|
100
|
-
const size = Number(
|
|
113
|
+
const size = Number(pp.size);
|
|
101
114
|
if (!Number.isFinite(size) || size <= 0) return null;
|
|
102
|
-
const pos =
|
|
115
|
+
const pos = pp.position && typeof pp.position === 'object' ? (pp.position as Record<string, unknown>) : {};
|
|
103
116
|
const corner = String(pos.corner ?? '');
|
|
104
117
|
if (!CORNERS.includes(corner)) return null;
|
|
105
118
|
const marginX = Number(pos.marginX);
|
|
@@ -111,94 +124,108 @@ function sanitizeUserConfig(raw: unknown): { pets: unknown[] } | null {
|
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
/** 宿主插件主体:注册 `/pet` 前缀路由。 */
|
|
114
|
-
|
|
127
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- DSH 注入的 ctx(webServer/locale 等 service 无静态类型)
|
|
128
|
+
export function apply(ctx: any): void {
|
|
115
129
|
const thumbRoot = join(PACKAGE_ROOT, 'assets', 'thumb');
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
// 用户数据根:配置与用户素材统一收敛于此(扩展包按 <插件id> 各自建目录)
|
|
131
|
+
const userRoot = join(resolveDshHome(), 'dsh-pet');
|
|
132
|
+
// 用户覆盖配置(pets / animations / animationWeights 覆盖片段)
|
|
133
|
+
const userConfigPath = join(userRoot, 'main-config.json');
|
|
134
|
+
// 用户动画目录(thumb 播放时优先于包内 assets/thumb)
|
|
135
|
+
const thumbUserRoot = join(userRoot, 'main-animation');
|
|
136
|
+
|
|
137
|
+
ctx.effect(
|
|
138
|
+
() =>
|
|
139
|
+
ctx.webServer.register({
|
|
140
|
+
kind: 'prefix',
|
|
141
|
+
path: ROUTE_PREFIX,
|
|
142
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
143
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
144
|
+
const rest = decodeURIComponent(url.pathname.slice(ROUTE_PREFIX.length + 1));
|
|
145
|
+
|
|
146
|
+
// 用户覆盖配置:/pet/config(GET / PUT / DELETE)
|
|
147
|
+
if (rest === 'config') {
|
|
148
|
+
if (req.method === 'GET') {
|
|
149
|
+
try {
|
|
150
|
+
const raw = await readFile(userConfigPath, 'utf8');
|
|
151
|
+
sendJson(res, 200, JSON.parse(raw));
|
|
152
|
+
} catch {
|
|
153
|
+
sendJson(res, 200, {}); // 无覆盖配置 → 空对象,client 回落默认
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (req.method === 'PUT') {
|
|
158
|
+
try {
|
|
159
|
+
const body = await readBody(req);
|
|
160
|
+
const parsed = JSON.parse(body);
|
|
161
|
+
const clean = sanitizeUserConfig(parsed);
|
|
162
|
+
if (!clean) {
|
|
163
|
+
sendJson(res, 400, {
|
|
164
|
+
error: 'invalid pet config: expected { pets:[{id,size,position:{corner,marginX,marginY}}] }',
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
await mkdir(userRoot, { recursive: true });
|
|
169
|
+
await writeFile(userConfigPath, JSON.stringify(clean, null, 2), 'utf8');
|
|
170
|
+
sendJson(res, 200, { ok: true });
|
|
171
|
+
} catch {
|
|
172
|
+
sendJson(res, 400, { error: 'invalid JSON body' });
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (req.method === 'DELETE') {
|
|
177
|
+
try {
|
|
178
|
+
await rm(userConfigPath, { force: true });
|
|
179
|
+
} catch {
|
|
180
|
+
/* 不存在也视为成功 */
|
|
181
|
+
}
|
|
182
|
+
sendJson(res, 200, { ok: true });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 配置文件路径(设置页「高级配置」展示用)
|
|
190
|
+
if (rest === 'config/meta') {
|
|
191
|
+
sendJson(res, 200, {
|
|
192
|
+
user: userConfigPath,
|
|
193
|
+
default: join(PACKAGE_ROOT, 'assets', 'config.jsonc'),
|
|
194
|
+
animations: thumbUserRoot,
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
135
197
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (!clean) {
|
|
144
|
-
sendJson(res, 400, { error: 'invalid pet config: expected { pets:[{id,size,position:{corner,marginX,marginY}}] }' });
|
|
198
|
+
|
|
199
|
+
// 配置文件(JSONC):/pet/config.jsonc → 包内 assets/config.jsonc
|
|
200
|
+
if (rest === 'config.jsonc') {
|
|
201
|
+
const cfgFile = join(PACKAGE_ROOT, 'assets', 'config.jsonc');
|
|
202
|
+
if (!existsSync(cfgFile)) {
|
|
203
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
204
|
+
res.end('dsh-pet: config.jsonc not found');
|
|
145
205
|
return;
|
|
146
206
|
}
|
|
147
|
-
await
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
207
|
+
await sendFile(res, cfgFile, MIME['.jsonc'] ?? 'application/octet-stream');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 动画文件:/pet/thumb/<file>,查找顺序 = 用户动画目录 → 包内 assets/thumb
|
|
212
|
+
const [scope, ...nameParts] = rest.split('/');
|
|
213
|
+
if (scope !== 'thumb') {
|
|
214
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
215
|
+
res.end('dsh-pet: expected /pet/thumb/<file>');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const fileName = nameParts.join('/');
|
|
219
|
+
const file = resolveExisting(thumbUserRoot, fileName) ?? resolveExisting(thumbRoot, fileName);
|
|
220
|
+
if (file === undefined) {
|
|
221
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
222
|
+
res.end('dsh-pet: asset not found');
|
|
223
|
+
return;
|
|
151
224
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
sendJson(res, 200, { ok: true });
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
sendJson(res, 405, { error: 'method not allowed' });
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 配置文件(JSONC):/pet/config.jsonc → 包内 assets/config.jsonc
|
|
166
|
-
if (rest === 'config.jsonc') {
|
|
167
|
-
const cfgFile = join(PACKAGE_ROOT, 'assets', 'config.jsonc');
|
|
168
|
-
if (!existsSync(cfgFile)) {
|
|
169
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
170
|
-
res.end('dsh-pet: config.jsonc not found');
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
await sendFile(res, cfgFile, MIME['.jsonc'] ?? 'application/octet-stream');
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// 第一段是 scope:thumb 或 full
|
|
178
|
-
const [scope, ...nameParts] = rest.split('/');
|
|
179
|
-
if (scope !== 'thumb' && scope !== 'full') {
|
|
180
|
-
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
181
|
-
res.end('dsh-pet: expected /pet/{thumb|full}/<file>');
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const fileName = nameParts.join('/');
|
|
186
|
-
const root = scope === 'thumb' ? thumbRoot : fullRoot;
|
|
187
|
-
const file = resolveAsset(root, fileName);
|
|
188
|
-
if (file === undefined) {
|
|
189
|
-
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
190
|
-
res.end('dsh-pet: invalid path');
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
if (!existsSync(file)) {
|
|
194
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
195
|
-
res.end(scope === 'full'
|
|
196
|
-
? `dsh-pet: original asset not downloaded yet — run the fetch-assets script to populate ${fullRoot}`
|
|
197
|
-
: 'dsh-pet: asset not found');
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
|
|
201
|
-
await sendFile(res, file, MIME[ext] ?? 'application/octet-stream');
|
|
202
|
-
},
|
|
203
|
-
}), 'dsh-pet: /pet asset route');
|
|
225
|
+
const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
|
|
226
|
+
await sendFile(res, file, MIME[ext] ?? 'application/octet-stream');
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
'dsh-pet: /pet asset route',
|
|
230
|
+
);
|
|
204
231
|
}
|