@luv1211/dsh-pet 0.1.1-rc.2
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 +22 -0
- package/README.i18n.yaml +6 -0
- package/README.md +35 -0
- package/README.zh.md +35 -0
- package/assets/deepseek-whale/pet.json +12 -0
- package/assets/deepseek-whale/spritesheet.webp +0 -0
- package/lib/index.js +1613 -0
- package/lib/invariant.js +19 -0
- package/lib/types/activity.d.ts +29 -0
- package/lib/types/activity.js +63 -0
- package/lib/types/catalog.d.ts +67 -0
- package/lib/types/catalog.js +258 -0
- package/lib/types/client.d.ts +13 -0
- package/lib/types/client.js +10 -0
- package/lib/types/host-image.d.ts +11 -0
- package/lib/types/host-image.js +48 -0
- package/lib/types/host-native.d.ts +17 -0
- package/lib/types/host-native.js +50 -0
- package/lib/types/index.d.ts +114 -0
- package/lib/types/index.js +631 -0
- package/lib/types/invariant.d.ts +9 -0
- package/lib/types/invariant.js +18 -0
- package/lib/types/path-opener.d.ts +50 -0
- package/lib/types/path-opener.js +161 -0
- package/lib/types/renderer.d.ts +42 -0
- package/lib/types/renderer.js +54 -0
- package/lib/types/runtime.d.ts +155 -0
- package/lib/types/runtime.js +314 -0
- package/lib/types/types.d.ts +147 -0
- package/lib/types/types.js +7 -0
- package/package.json +111 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global desktop-pet domain: durable user preferences and a live,
|
|
3
|
+
* session-event-derived activity read model for companion clients.
|
|
4
|
+
* @module @luv1211/dsh-pet
|
|
5
|
+
*/
|
|
6
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
7
|
+
var useValue = arguments.length > 2;
|
|
8
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
9
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
10
|
+
}
|
|
11
|
+
return useValue ? value : void 0;
|
|
12
|
+
};
|
|
13
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
14
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
15
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
16
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
17
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
18
|
+
var _, done = false;
|
|
19
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
20
|
+
var context = {};
|
|
21
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
22
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
23
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
24
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
25
|
+
if (kind === "accessor") {
|
|
26
|
+
if (result === void 0) continue;
|
|
27
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
28
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
29
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
30
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
31
|
+
}
|
|
32
|
+
else if (_ = accept(result)) {
|
|
33
|
+
if (kind === "field") initializers.unshift(_);
|
|
34
|
+
else descriptor[key] = _;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
38
|
+
done = true;
|
|
39
|
+
};
|
|
40
|
+
import z from '@deepseek-ai/schemastery';
|
|
41
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
42
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
43
|
+
import { PetActivityProjection } from "./activity.js";
|
|
44
|
+
import { PetCatalogStore } from "./catalog.js";
|
|
45
|
+
import { createPetNativeActions } from "./host-native.js";
|
|
46
|
+
import { FRAME_AT_SOURCE } from '@luv1211/dsh-pet-compat';
|
|
47
|
+
import { DEFAULT_PET_ID, DEFAULT_PET_SIZE_PX, PET_COMPAT_ATLAS, PET_PREFERENCE_VERSION, comparePetActivities, petStatusForHostActivity, petWidthForSize, resolvePetPreference, validatePetPackage, validatePetSize, } from "./runtime.js";
|
|
48
|
+
export { DEFAULT_PET_ID, DEFAULT_PET_SIZE_PX, MAX_PET_SIZE_PX, MIN_PET_SIZE_PX, PET_PREFERENCE_VERSION, comparePetActivities, defaultPetPreference, isDragMovement, petStatusForHostActivity, petWidthForSize, resolvePetPreference, selectLookDirection, selectPetPresentation, validatePetSize, validatePetPackage, } from "./runtime.js";
|
|
49
|
+
export { petSpriteAvatar, petSpriteFrame } from "./renderer.js";
|
|
50
|
+
/** Schema of the durable `pet` settings namespace. */
|
|
51
|
+
const petPreferenceSchema = z.transform(z.object({
|
|
52
|
+
version: z.number().default(PET_PREFERENCE_VERSION),
|
|
53
|
+
selectedPetId: z.string().default(DEFAULT_PET_ID),
|
|
54
|
+
awake: z.boolean().default(true),
|
|
55
|
+
sizePx: z.number().default(DEFAULT_PET_SIZE_PX),
|
|
56
|
+
}), value => resolvePetPreference(value), true).default({});
|
|
57
|
+
/** Reject a preference that cannot select a package or preserve its document meaning. */
|
|
58
|
+
function validatePetPreference(value) {
|
|
59
|
+
if (value.selectedPetId.length === 0)
|
|
60
|
+
throw new TypeError('pet preference selectedPetId must not be empty');
|
|
61
|
+
validatePetSize(value.sizePx);
|
|
62
|
+
}
|
|
63
|
+
/** Pet service (`ctx.pets`): one durable preference writer and activity aggregator. */
|
|
64
|
+
let PetService = (() => {
|
|
65
|
+
let _classSuper = TypertRemoteService;
|
|
66
|
+
let _instanceExtraInitializers = [];
|
|
67
|
+
let _getSnapshot_decorators;
|
|
68
|
+
let _getCatalog_decorators;
|
|
69
|
+
let _importPetPackage_decorators;
|
|
70
|
+
let _refreshCatalog_decorators;
|
|
71
|
+
let _updatePetPackage_decorators;
|
|
72
|
+
let _openPetFolder_decorators;
|
|
73
|
+
let _selectPet_decorators;
|
|
74
|
+
let _setSize_decorators;
|
|
75
|
+
let _setAwake_decorators;
|
|
76
|
+
return class PetService extends _classSuper {
|
|
77
|
+
static {
|
|
78
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
79
|
+
_getSnapshot_decorators = [Remote('getSnapshot')];
|
|
80
|
+
_getCatalog_decorators = [Remote('getCatalog')];
|
|
81
|
+
_importPetPackage_decorators = [Remote('importPetPackage')];
|
|
82
|
+
_refreshCatalog_decorators = [Remote('refreshCatalog')];
|
|
83
|
+
_updatePetPackage_decorators = [Remote('updatePetPackage')];
|
|
84
|
+
_openPetFolder_decorators = [Remote('openPetFolder')];
|
|
85
|
+
_selectPet_decorators = [Remote('selectPet')];
|
|
86
|
+
_setSize_decorators = [Remote('setSize')];
|
|
87
|
+
_setAwake_decorators = [Remote('setAwake')];
|
|
88
|
+
__esDecorate(this, null, _getSnapshot_decorators, { kind: "method", name: "getSnapshot", static: false, private: false, access: { has: obj => "getSnapshot" in obj, get: obj => obj.getSnapshot }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
89
|
+
__esDecorate(this, null, _getCatalog_decorators, { kind: "method", name: "getCatalog", static: false, private: false, access: { has: obj => "getCatalog" in obj, get: obj => obj.getCatalog }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
90
|
+
__esDecorate(this, null, _importPetPackage_decorators, { kind: "method", name: "importPetPackage", static: false, private: false, access: { has: obj => "importPetPackage" in obj, get: obj => obj.importPetPackage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
91
|
+
__esDecorate(this, null, _refreshCatalog_decorators, { kind: "method", name: "refreshCatalog", static: false, private: false, access: { has: obj => "refreshCatalog" in obj, get: obj => obj.refreshCatalog }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
92
|
+
__esDecorate(this, null, _updatePetPackage_decorators, { kind: "method", name: "updatePetPackage", static: false, private: false, access: { has: obj => "updatePetPackage" in obj, get: obj => obj.updatePetPackage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
93
|
+
__esDecorate(this, null, _openPetFolder_decorators, { kind: "method", name: "openPetFolder", static: false, private: false, access: { has: obj => "openPetFolder" in obj, get: obj => obj.openPetFolder }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
94
|
+
__esDecorate(this, null, _selectPet_decorators, { kind: "method", name: "selectPet", static: false, private: false, access: { has: obj => "selectPet" in obj, get: obj => obj.selectPet }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
95
|
+
__esDecorate(this, null, _setSize_decorators, { kind: "method", name: "setSize", static: false, private: false, access: { has: obj => "setSize" in obj, get: obj => obj.setSize }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
96
|
+
__esDecorate(this, null, _setAwake_decorators, { kind: "method", name: "setAwake", static: false, private: false, access: { has: obj => "setAwake" in obj, get: obj => obj.setAwake }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
97
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
98
|
+
}
|
|
99
|
+
static inject = ['settings'];
|
|
100
|
+
static Config = z.object({
|
|
101
|
+
dshHome: z.string(),
|
|
102
|
+
petRoot: z.string(),
|
|
103
|
+
maxManifestBytes: z.natural().default(16 * 1024),
|
|
104
|
+
maxSpriteBytes: z.natural().default(16 * 1024 * 1024),
|
|
105
|
+
decodeTimeoutMs: z.natural().min(1).default(10_000),
|
|
106
|
+
});
|
|
107
|
+
scope = __runInitializers(this, _instanceExtraInitializers);
|
|
108
|
+
preference;
|
|
109
|
+
catalogStore;
|
|
110
|
+
catalog;
|
|
111
|
+
activities = new Map();
|
|
112
|
+
tail = Promise.resolve();
|
|
113
|
+
constructor(ctx, config = {}) {
|
|
114
|
+
super(ctx, 'pets');
|
|
115
|
+
this.catalogStore = new PetCatalogStore(config);
|
|
116
|
+
this.catalog = this.catalogStore.getCatalog();
|
|
117
|
+
this.scope = ctx.settings.register(settingsNamespace('pet'), petPreferenceSchema, {
|
|
118
|
+
validate: validatePetPreference,
|
|
119
|
+
});
|
|
120
|
+
this.preference = this.scope.get();
|
|
121
|
+
if (!this.catalogStore.has(this.preference.selectedPetId)) {
|
|
122
|
+
throw new TypeError(`pet ${this.preference.selectedPetId} was not found in the pet catalog`);
|
|
123
|
+
}
|
|
124
|
+
ctx.effect(() => this.scope.watch((next) => {
|
|
125
|
+
this.preference = next;
|
|
126
|
+
this.publish();
|
|
127
|
+
}), 'dsh-pet: preference watch');
|
|
128
|
+
ctx.effect(() => this.catalogStore.subscribe((catalog) => {
|
|
129
|
+
this.catalog = catalog;
|
|
130
|
+
this.publish();
|
|
131
|
+
}), 'dsh-pet: catalog watch');
|
|
132
|
+
const activitySource = ctx.get('petActivity') ?? new PetActivityProjection(ctx);
|
|
133
|
+
this.applyActivity(activitySource.getSnapshot());
|
|
134
|
+
ctx.effect(() => activitySource.subscribe((records) => {
|
|
135
|
+
this.applyActivity(records);
|
|
136
|
+
}), 'dsh-pet: activity projection');
|
|
137
|
+
const desktopCompanion = ctx.get('desktopCompanion');
|
|
138
|
+
const webServer = ctx.get('webServer');
|
|
139
|
+
if (desktopCompanion !== undefined && webServer !== undefined) {
|
|
140
|
+
ctx.effect(() => desktopCompanion.register({
|
|
141
|
+
id: 'pet',
|
|
142
|
+
entryPath: '/__dsh/pet/overlay',
|
|
143
|
+
width: petWidthForSize(DEFAULT_PET_SIZE_PX),
|
|
144
|
+
height: DEFAULT_PET_SIZE_PX,
|
|
145
|
+
capabilities: {
|
|
146
|
+
drag: true,
|
|
147
|
+
pointerInteraction: true,
|
|
148
|
+
resize: {
|
|
149
|
+
minWidth: petWidthForSize(80),
|
|
150
|
+
maxWidth: petWidthForSize(224),
|
|
151
|
+
minHeight: 80,
|
|
152
|
+
maxHeight: 224,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}), 'dsh-pet: desktop companion');
|
|
156
|
+
ctx.effect(() => webServer.register({
|
|
157
|
+
kind: 'exact', path: '/__dsh/pet/overlay', handler: (_req, res) => {
|
|
158
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
159
|
+
res.end(createPetOverlayHtml());
|
|
160
|
+
},
|
|
161
|
+
}), 'dsh-pet: companion page');
|
|
162
|
+
ctx.effect(() => webServer.register({
|
|
163
|
+
kind: 'exact', path: '/__dsh/pet/overlay-state', handler: (_req, res) => {
|
|
164
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
165
|
+
res.end(JSON.stringify(this.getSnapshot()));
|
|
166
|
+
},
|
|
167
|
+
}), 'dsh-pet: companion state');
|
|
168
|
+
ctx.effect(() => webServer.register({
|
|
169
|
+
kind: 'exact', path: '/__dsh/pet/overlay-awake', handler: (req, res) => {
|
|
170
|
+
void this.handleOverlayAwake(req, res);
|
|
171
|
+
},
|
|
172
|
+
}), 'dsh-pet: companion awake write');
|
|
173
|
+
}
|
|
174
|
+
if (webServer !== undefined)
|
|
175
|
+
ctx.effect(() => webServer.register({
|
|
176
|
+
kind: 'prefix',
|
|
177
|
+
path: '/__dsh/pet/assets',
|
|
178
|
+
handler: (req, res) => {
|
|
179
|
+
let pathname;
|
|
180
|
+
try {
|
|
181
|
+
pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://dsh.local').pathname);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
res.writeHead(404);
|
|
185
|
+
res.end();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const match = /^\/__dsh\/pet\/assets\/([^/]+)\/spritesheet\.webp$/.exec(pathname);
|
|
189
|
+
const asset = match?.[1] === undefined ? undefined : this.catalogStore.getAsset(match[1]);
|
|
190
|
+
if (asset === undefined) {
|
|
191
|
+
res.writeHead(404);
|
|
192
|
+
res.end();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(200, { 'content-type': 'image/webp', 'cache-control': 'no-store' });
|
|
196
|
+
res.end(Buffer.from(asset));
|
|
197
|
+
},
|
|
198
|
+
}), 'dsh-pet: catalog assets');
|
|
199
|
+
ctx.effect(() => { return () => { this.catalogStore.dispose(); }; }, 'dsh-pet: catalog dispose');
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The host-native operations the current composition provides. Resolved
|
|
203
|
+
* per access, not captured at construction: the `petNative` provider mounts
|
|
204
|
+
* as a later tree row than this service, and the composed directory picker
|
|
205
|
+
* can also enter the context later. An explicit `petNative` provider wins;
|
|
206
|
+
* otherwise the actions derive from the composed directory picker when it
|
|
207
|
+
* serves the native backend.
|
|
208
|
+
* @returns the native operations, or `undefined` in browser-only compositions.
|
|
209
|
+
*/
|
|
210
|
+
get nativeActions() {
|
|
211
|
+
return this.ctx.get('petNative') ?? createPetNativeActions(this.ctx.get('directoryPicker')?.capability());
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Read the latest durable preference and every current activity record.
|
|
215
|
+
* @returns a detached, deterministically ordered snapshot.
|
|
216
|
+
*/
|
|
217
|
+
getSnapshot() {
|
|
218
|
+
const activities = [...this.activities.values()].sort(comparePetActivities).map(activity => ({ ...activity }));
|
|
219
|
+
const selected = activities[0];
|
|
220
|
+
return {
|
|
221
|
+
preference: { ...this.preference },
|
|
222
|
+
catalog: { pets: this.catalog.pets.map(pet => ({ ...pet })) },
|
|
223
|
+
petRoot: this.catalogStore.petRoot,
|
|
224
|
+
capabilities: {
|
|
225
|
+
canImport: this.nativeActions !== undefined,
|
|
226
|
+
canOpenFolder: this.nativeActions !== undefined,
|
|
227
|
+
},
|
|
228
|
+
activities,
|
|
229
|
+
...(selected === undefined ? {} : { selectedActivity: { ...selected } }),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Read the current validated built-in and user package descriptors.
|
|
234
|
+
* @returns a detached catalog of validated package descriptors.
|
|
235
|
+
*/
|
|
236
|
+
getCatalog() {
|
|
237
|
+
return { pets: this.catalog.pets.map(pet => ({ ...pet })) };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Import one validated package selected by the native host, without accepting a client path.
|
|
241
|
+
* @returns the publication, cancellation, or host-availability result.
|
|
242
|
+
*/
|
|
243
|
+
async importPetPackage() {
|
|
244
|
+
const native = this.nativeActions;
|
|
245
|
+
if (native === undefined)
|
|
246
|
+
return { outcome: 'host-unavailable' };
|
|
247
|
+
const selected = await native.pickPetPackage();
|
|
248
|
+
if (selected === null)
|
|
249
|
+
return { outcome: 'cancelled' };
|
|
250
|
+
const pet = this.catalogStore.importPackage(selected.manifestBytes, selected.spritesheetBytes);
|
|
251
|
+
return { outcome: 'published', pet };
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Rescan the user package root and republish the catalog, so packages added
|
|
255
|
+
* or removed on disk appear without a restart.
|
|
256
|
+
* @returns the fresh snapshot after the rescan.
|
|
257
|
+
*/
|
|
258
|
+
refreshCatalog() {
|
|
259
|
+
this.catalogStore.reload();
|
|
260
|
+
return this.getSnapshot();
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Replace one existing user package's content with bytes selected by the
|
|
264
|
+
* native host. The picked manifest must name the requested package, and a
|
|
265
|
+
* mismatch or a non-user target fails before anything is written.
|
|
266
|
+
* @param petId - user package identifier to replace.
|
|
267
|
+
* @returns the replacement, cancellation, or host-availability result.
|
|
268
|
+
*/
|
|
269
|
+
async updatePetPackage(petId) {
|
|
270
|
+
const native = this.nativeActions;
|
|
271
|
+
if (native === undefined)
|
|
272
|
+
return { outcome: 'host-unavailable' };
|
|
273
|
+
const selected = await native.pickPetPackage();
|
|
274
|
+
if (selected === null)
|
|
275
|
+
return { outcome: 'cancelled' };
|
|
276
|
+
const picked = validatePetPackage(selected.manifestBytes, selected.spritesheetBytes, { source: 'user' });
|
|
277
|
+
if (picked.id !== petId)
|
|
278
|
+
throw new TypeError(`selected pet package ${picked.id} does not match update target ${petId}`);
|
|
279
|
+
const pet = this.catalogStore.replacePackage(selected.manifestBytes, selected.spritesheetBytes);
|
|
280
|
+
return { outcome: 'published', pet };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Ask the native host to open the configured DSH pet directory.
|
|
284
|
+
* @returns the opened or host-availability result.
|
|
285
|
+
*/
|
|
286
|
+
async openPetFolder() {
|
|
287
|
+
const native = this.nativeActions;
|
|
288
|
+
if (native === undefined)
|
|
289
|
+
return { outcome: 'host-unavailable' };
|
|
290
|
+
this.catalogStore.ensureRoot();
|
|
291
|
+
await native.openPetFolder(this.catalogStore.petRoot);
|
|
292
|
+
return { outcome: 'opened' };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Select a built-in or previously imported pet package.
|
|
296
|
+
* @param selectedPetId - non-empty package identifier.
|
|
297
|
+
* @returns the committed fresh snapshot.
|
|
298
|
+
*/
|
|
299
|
+
async selectPet(selectedPetId) {
|
|
300
|
+
if (selectedPetId.length === 0)
|
|
301
|
+
throw new TypeError('pet preference selectedPetId must not be empty');
|
|
302
|
+
if (!this.catalogStore.has(selectedPetId))
|
|
303
|
+
throw new TypeError(`pet ${selectedPetId} was not found in the pet catalog`);
|
|
304
|
+
return this.commit(preference => ({ ...preference, selectedPetId }));
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Persist one validated logical CSS height for the selected companion.
|
|
308
|
+
* @param sizePx - logical CSS height between the configured pet limits.
|
|
309
|
+
* @returns the committed fresh snapshot.
|
|
310
|
+
*/
|
|
311
|
+
async setSize(sizePx) {
|
|
312
|
+
validatePetSize(sizePx);
|
|
313
|
+
return this.commit(preference => ({ ...preference, sizePx }));
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Wake or tuck the selected companion.
|
|
317
|
+
* @param awake - whether companion clients render the selected pet awake.
|
|
318
|
+
* @returns the committed fresh snapshot.
|
|
319
|
+
*/
|
|
320
|
+
setAwake(awake) {
|
|
321
|
+
return this.commit(preference => ({ ...preference, awake }));
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Serve the companion page's one awake write (`POST {awake: boolean}`),
|
|
325
|
+
* answering with the committed snapshot so the page applies it immediately.
|
|
326
|
+
* The cross-site write fence matches the API gateway: only the JSON media
|
|
327
|
+
* type is accepted, which forces a CORS preflight this loopback server never
|
|
328
|
+
* answers, so a "simple" cross-site POST cannot tuck the pet blind.
|
|
329
|
+
* @param req - the raw request; method, media type, and body are validated here.
|
|
330
|
+
* @param res - the raw response; every failure path answers a status without a body.
|
|
331
|
+
*/
|
|
332
|
+
async handleOverlayAwake(req, res) {
|
|
333
|
+
const fail = (status) => {
|
|
334
|
+
res.writeHead(status);
|
|
335
|
+
res.end();
|
|
336
|
+
};
|
|
337
|
+
if (req.method !== 'POST') {
|
|
338
|
+
fail(405);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const mediaType = req.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase();
|
|
342
|
+
if (mediaType !== 'application/json') {
|
|
343
|
+
fail(415);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
let payload;
|
|
347
|
+
try {
|
|
348
|
+
payload = JSON.parse(await readBoundedBody(req, OVERLAY_AWAKE_BODY_LIMIT_BYTES));
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
fail(error instanceof CompanionBodyLimitError ? 413 : 400);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
355
|
+
fail(400);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const keys = Object.keys(payload);
|
|
359
|
+
if (keys.length !== 1 || keys[0] !== 'awake') {
|
|
360
|
+
fail(400);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const awake = payload.awake;
|
|
364
|
+
if (typeof awake !== 'boolean') {
|
|
365
|
+
fail(400);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
let snapshot;
|
|
369
|
+
try {
|
|
370
|
+
snapshot = await this.setAwake(awake);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// The preference write failed (settings persistence); answer without hanging.
|
|
374
|
+
fail(500);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
378
|
+
res.end(JSON.stringify(snapshot));
|
|
379
|
+
}
|
|
380
|
+
/** Replace the local adapter state from one detached host projection. */
|
|
381
|
+
applyActivity(records) {
|
|
382
|
+
const next = new Map();
|
|
383
|
+
for (const record of records) {
|
|
384
|
+
const status = petStatusForHostActivity(record);
|
|
385
|
+
if (status === undefined)
|
|
386
|
+
continue;
|
|
387
|
+
next.set(String(record.sessionId), {
|
|
388
|
+
sessionId: record.sessionId,
|
|
389
|
+
title: record.title,
|
|
390
|
+
status,
|
|
391
|
+
since: record.since,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
this.activities.clear();
|
|
395
|
+
for (const [sessionId, activity] of next)
|
|
396
|
+
this.activities.set(sessionId, activity);
|
|
397
|
+
this.publish();
|
|
398
|
+
}
|
|
399
|
+
/** Serialize one preference write and return only after durable persistence. */
|
|
400
|
+
commit(mutate) {
|
|
401
|
+
const run = async () => {
|
|
402
|
+
const next = mutate(this.preference);
|
|
403
|
+
try {
|
|
404
|
+
await this.scope.replace({ ...next });
|
|
405
|
+
this.preference = next;
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
this.preference = this.scope.get();
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
const attempt = this.tail.then(run);
|
|
413
|
+
this.tail = attempt.then(() => undefined, () => undefined);
|
|
414
|
+
return attempt.then(() => this.getSnapshot());
|
|
415
|
+
}
|
|
416
|
+
/** Publish a detached read model after a preference commit or activity transition. */
|
|
417
|
+
publish() {
|
|
418
|
+
this.ctx.emit('pet/update', this.getSnapshot());
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
})();
|
|
422
|
+
export { PetService };
|
|
423
|
+
const PET_OVERLAY_RUNTIME_CONFIG = JSON.stringify({
|
|
424
|
+
atlas: PET_COMPAT_ATLAS,
|
|
425
|
+
});
|
|
426
|
+
/** Maximum accepted companion write body in bytes; the payload is one boolean. */
|
|
427
|
+
const OVERLAY_AWAKE_BODY_LIMIT_BYTES = 1_024;
|
|
428
|
+
/** Marker for a request body that drained past `OVERLAY_AWAKE_BODY_LIMIT_BYTES`. */
|
|
429
|
+
class CompanionBodyLimitError extends Error {
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Read one request body as UTF-8. Chunks past the byte cap are drained but
|
|
433
|
+
* discarded — bounded memory, and the client still receives the error status.
|
|
434
|
+
* @param req - the request whose body is draining.
|
|
435
|
+
* @param limitBytes - inclusive byte cap before the read turns lossy.
|
|
436
|
+
* @returns the full body, or rejects with `CompanionBodyLimitError` when it passed the cap.
|
|
437
|
+
*/
|
|
438
|
+
function readBoundedBody(req, limitBytes) {
|
|
439
|
+
return new Promise((resolve, reject) => {
|
|
440
|
+
const chunks = [];
|
|
441
|
+
let total = 0;
|
|
442
|
+
let overLimit = false;
|
|
443
|
+
req.on('data', (chunk) => {
|
|
444
|
+
total += chunk.length;
|
|
445
|
+
if (total > limitBytes)
|
|
446
|
+
overLimit = true;
|
|
447
|
+
else
|
|
448
|
+
chunks.push(chunk);
|
|
449
|
+
});
|
|
450
|
+
req.on('end', () => {
|
|
451
|
+
if (overLimit)
|
|
452
|
+
reject(new CompanionBodyLimitError());
|
|
453
|
+
else
|
|
454
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
455
|
+
});
|
|
456
|
+
req.on('error', reject);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/** Return a static safe DOM document; live snapshot values arrive through JSON. */
|
|
460
|
+
function createPetOverlayHtml() {
|
|
461
|
+
return `<!doctype html>
|
|
462
|
+
<meta name="color-scheme" content="dark">
|
|
463
|
+
<style>
|
|
464
|
+
html,body{margin:0;overflow:hidden;background:transparent;font:12px system-ui;color:white}
|
|
465
|
+
#pet-root{position:fixed;inset:0;pointer-events:none}
|
|
466
|
+
#pet-button{position:absolute;left:0;top:0;border:0;padding:0;background:transparent;cursor:grab;color:inherit;pointer-events:auto;user-select:none;-webkit-user-select:none}
|
|
467
|
+
#pet-button:active{cursor:grabbing}
|
|
468
|
+
#pet-sprite{display:block;background-repeat:no-repeat;image-rendering:pixelated;filter:drop-shadow(0 2px 3px #000)}
|
|
469
|
+
#pet-label{display:block;text-align:center;text-shadow:0 1px 2px #000;white-space:nowrap}
|
|
470
|
+
#pet-menu{position:fixed;z-index:2;border:1px solid #4a4f5a;border-radius:6px;background:#242832;box-shadow:0 4px 10px #000a;padding:3px;pointer-events:auto}
|
|
471
|
+
#pet-menu button{display:block;border:0;background:transparent;color:inherit;font:inherit;text-align:left;padding:4px 12px;border-radius:4px;cursor:default;white-space:nowrap}
|
|
472
|
+
#pet-menu button:hover{background:#3b4152}
|
|
473
|
+
</style>
|
|
474
|
+
<main id="pet-root"><button id="pet-button" type="button"><span id="pet-sprite" aria-hidden="true"></span><span id="pet-label"></span></button><div id="pet-menu" role="menu" hidden><button id="pet-menu-close" type="button" role="menuitem">关闭宠物</button></div></main>
|
|
475
|
+
<script>
|
|
476
|
+
const config=${PET_OVERLAY_RUNTIME_CONFIG};
|
|
477
|
+
const frameAt=${FRAME_AT_SOURCE};
|
|
478
|
+
const button=document.getElementById('pet-button');
|
|
479
|
+
const sprite=document.getElementById('pet-sprite');
|
|
480
|
+
const label=document.getElementById('pet-label');
|
|
481
|
+
const menu=document.getElementById('pet-menu');
|
|
482
|
+
const menuClose=document.getElementById('pet-menu-close');
|
|
483
|
+
const api=window.dshDesktopCompanion;
|
|
484
|
+
let current=null;
|
|
485
|
+
let hover=false;
|
|
486
|
+
let drag=null;
|
|
487
|
+
let menuOpen=false;
|
|
488
|
+
let moved=false;
|
|
489
|
+
let animationName='idle';
|
|
490
|
+
let activityAnimationName='idle';
|
|
491
|
+
let frameStarted=performance.now();
|
|
492
|
+
const motionQuery=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):null;
|
|
493
|
+
let reducedMotion=motionQuery?.matches===true;
|
|
494
|
+
motionQuery?.addEventListener?.('change',event=>{reducedMotion=event.matches;frameStarted=performance.now()});
|
|
495
|
+
function activityState(status){
|
|
496
|
+
if(status==='needs-input')return 'waiting';
|
|
497
|
+
if(status==='blocked')return 'failed';
|
|
498
|
+
if(status==='ready')return 'review';
|
|
499
|
+
if(status==='running')return 'running';
|
|
500
|
+
return 'idle';
|
|
501
|
+
}
|
|
502
|
+
function selectedPet(){
|
|
503
|
+
if(current===null)return null;
|
|
504
|
+
return current.catalog.pets.find(pet=>pet.id===current.preference.selectedPetId)||current.catalog.pets[0]||null;
|
|
505
|
+
}
|
|
506
|
+
function applySnapshot(next){
|
|
507
|
+
const previousPet=current===null?null:selectedPet();
|
|
508
|
+
const previousActivity=activityAnimationName;
|
|
509
|
+
const previousSize=current?.preference.sizePx;
|
|
510
|
+
const nextActivity=activityState(next.selectedActivity?.status);
|
|
511
|
+
current=next;
|
|
512
|
+
const pet=selectedPet();
|
|
513
|
+
const awake=next.preference.awake&&pet!==null;
|
|
514
|
+
if(previousActivity!==nextActivity||previousPet?.id!==pet?.id||previousPet?.assetUrl!==pet?.assetUrl||previousSize!==next.preference.sizePx){
|
|
515
|
+
activityAnimationName=nextActivity;
|
|
516
|
+
animationName=nextActivity;
|
|
517
|
+
frameStarted=performance.now();
|
|
518
|
+
}
|
|
519
|
+
// Keep the Electron companion window sized to the sprite: the shell clamps
|
|
520
|
+
// the request into the registered resize capability (74..207 x 80..224), and
|
|
521
|
+
// the first sample (previousSize===undefined) reconciles a window restored at
|
|
522
|
+
// a stale height with the current preference.
|
|
523
|
+
if(previousSize!==next.preference.sizePx&&api){
|
|
524
|
+
void api.resize({width:Math.round(next.preference.sizePx*config.atlas.cellWidth/config.atlas.cellHeight),height:next.preference.sizePx}).catch(()=>{});
|
|
525
|
+
}
|
|
526
|
+
button.hidden=!awake;
|
|
527
|
+
if(!awake){
|
|
528
|
+
closeMenu();
|
|
529
|
+
applyPointerInteraction();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
button.style.width=String(Math.round(next.preference.sizePx*config.atlas.cellWidth/config.atlas.cellHeight))+'px';
|
|
533
|
+
button.style.height=String(next.preference.sizePx)+'px';
|
|
534
|
+
label.textContent=next.selectedActivity?next.selectedActivity.status:'ready';
|
|
535
|
+
button.setAttribute('aria-label','DeepSeek Harness pet: '+label.textContent);
|
|
536
|
+
}
|
|
537
|
+
// The companion window must not eat clicks while nothing visible can receive
|
|
538
|
+
// them: interactive only while the pet is awake and the pointer is over the
|
|
539
|
+
// pet, a menu is open, or a drag owns the pointer.
|
|
540
|
+
function applyPointerInteraction(){
|
|
541
|
+
const interactive=current!==null&¤t.preference.awake&&(hover||menuOpen||drag!==null);
|
|
542
|
+
if(api)void api.setPointerInteraction({interactive}).catch(()=>{});
|
|
543
|
+
}
|
|
544
|
+
function openMenu(x,y){
|
|
545
|
+
menu.hidden=false;
|
|
546
|
+
menuOpen=true;
|
|
547
|
+
menu.style.left=String(Math.max(0,Math.min(x,window.innerWidth-menu.offsetWidth)))+'px';
|
|
548
|
+
menu.style.top=String(Math.max(0,Math.min(y,window.innerHeight-menu.offsetHeight)))+'px';
|
|
549
|
+
applyPointerInteraction();
|
|
550
|
+
}
|
|
551
|
+
function closeMenu(){
|
|
552
|
+
if(!menuOpen)return;
|
|
553
|
+
menu.hidden=true;
|
|
554
|
+
menuOpen=false;
|
|
555
|
+
applyPointerInteraction();
|
|
556
|
+
}
|
|
557
|
+
async function closePet(){
|
|
558
|
+
closeMenu();
|
|
559
|
+
try{
|
|
560
|
+
const response=await fetch('/__dsh/pet/overlay-awake',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({awake:false})});
|
|
561
|
+
if(response.ok)applySnapshot(await response.json());
|
|
562
|
+
}catch{
|
|
563
|
+
// Write failure: the menu closing is the only immediate effect; the next
|
|
564
|
+
// overlay-state sync re-applies the authoritative preference.
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function renderFrame(time){
|
|
568
|
+
const pet=selectedPet();
|
|
569
|
+
if(current!==null&&pet!==null&¤t.preference.awake){
|
|
570
|
+
const name=hover?'jumping':animationName;
|
|
571
|
+
const elapsed=time-frameStarted;
|
|
572
|
+
const selection=frameAt(pet.animations,name,elapsed,reducedMotion,new Set());
|
|
573
|
+
const spriteIndex=selection?.spriteIndex??0;
|
|
574
|
+
const displayRow=Math.floor(spriteIndex/config.atlas.columns);
|
|
575
|
+
const displayColumn=spriteIndex%config.atlas.columns;
|
|
576
|
+
sprite.style.width=String(Math.round(current.preference.sizePx*config.atlas.cellWidth/config.atlas.cellHeight))+'px';
|
|
577
|
+
sprite.style.height=String(current.preference.sizePx)+'px';
|
|
578
|
+
sprite.style.backgroundImage='url('+pet.assetUrl+')';
|
|
579
|
+
sprite.style.backgroundSize=String(Math.round(current.preference.sizePx*config.atlas.columns*config.atlas.cellWidth/config.atlas.cellHeight))+'px '+String(current.preference.sizePx*config.atlas.rows)+'px';
|
|
580
|
+
sprite.style.backgroundPosition=String(-displayColumn*Math.round(current.preference.sizePx*config.atlas.cellWidth/config.atlas.cellHeight))+'px '+String(-displayRow*current.preference.sizePx)+'px';
|
|
581
|
+
}
|
|
582
|
+
requestAnimationFrame(renderFrame);
|
|
583
|
+
}
|
|
584
|
+
async function syncSnapshot(){
|
|
585
|
+
if(drag!==null)return;
|
|
586
|
+
try{
|
|
587
|
+
const response=await fetch('/__dsh/pet/overlay-state',{cache:'no-store'});
|
|
588
|
+
if(response.ok)applySnapshot(await response.json());
|
|
589
|
+
}catch{}
|
|
590
|
+
}
|
|
591
|
+
button.addEventListener('pointerenter',()=>{hover=true;applyPointerInteraction()});
|
|
592
|
+
button.addEventListener('pointerleave',()=>{hover=false;applyPointerInteraction()});
|
|
593
|
+
button.addEventListener('pointerdown',event=>{
|
|
594
|
+
if(event.button!==0)return;
|
|
595
|
+
event.preventDefault();
|
|
596
|
+
button.setPointerCapture(event.pointerId);
|
|
597
|
+
const pending={pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,sequence:0};
|
|
598
|
+
drag=pending;moved=false;
|
|
599
|
+
if(api)void api.startDrag({pointerId:event.pointerId,screenX:event.screenX,screenY:event.screenY}).then(result=>{if(drag===pending)pending.dragId=result.dragId}).catch(()=>{if(drag===pending)drag=null});
|
|
600
|
+
});
|
|
601
|
+
button.addEventListener('pointermove',event=>{
|
|
602
|
+
if(drag===null||drag.pointerId!==event.pointerId)return;
|
|
603
|
+
if(Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)>4)moved=true;
|
|
604
|
+
if(api&&drag.dragId!==undefined){drag.sequence+=1;void api.moveDrag({dragId:drag.dragId,pointerId:drag.pointerId,sequence:drag.sequence,screenX:event.screenX,screenY:event.screenY}).then(result=>{if(result.accepted&&result.direction!=='neutral')animationName=result.direction==='left'?'running-left':'running-right'}).catch(()=>{})}
|
|
605
|
+
});
|
|
606
|
+
button.addEventListener('pointerup',event=>{
|
|
607
|
+
const pending=drag;if(pending===null||pending.pointerId!==event.pointerId)return;
|
|
608
|
+
drag=null;
|
|
609
|
+
if(api&&pending.dragId!==undefined){pending.sequence+=1;void api.endDrag({dragId:pending.dragId,pointerId:pending.pointerId,sequence:pending.sequence,screenX:event.screenX,screenY:event.screenY}).catch(()=>{})}
|
|
610
|
+
});
|
|
611
|
+
button.addEventListener('pointercancel',event=>{
|
|
612
|
+
const pending=drag;if(pending===null||pending.pointerId!==event.pointerId)return;
|
|
613
|
+
drag=null;
|
|
614
|
+
if(api&&pending.dragId!==undefined)void api.cancelDrag({dragId:pending.dragId,pointerId:pending.pointerId}).catch(()=>{});
|
|
615
|
+
});
|
|
616
|
+
button.addEventListener('click',()=>{if(moved){moved=false;return}if(api)void api.focusMain().catch(()=>{})});
|
|
617
|
+
button.addEventListener('contextmenu',event=>{
|
|
618
|
+
event.preventDefault();
|
|
619
|
+
if(drag!==null)return;
|
|
620
|
+
openMenu(event.clientX,event.clientY);
|
|
621
|
+
});
|
|
622
|
+
menuClose.addEventListener('click',()=>{void closePet()});
|
|
623
|
+
window.addEventListener('pointerdown',event=>{if(menuOpen&&!menu.contains(event.target))closeMenu()},true);
|
|
624
|
+
window.addEventListener('keydown',event=>{if(event.key==='Escape')closeMenu()});
|
|
625
|
+
void syncSnapshot();
|
|
626
|
+
setInterval(()=>void syncSnapshot(),750);
|
|
627
|
+
requestAnimationFrame(renderFrame);
|
|
628
|
+
</script>`;
|
|
629
|
+
}
|
|
630
|
+
export default PetService;
|
|
631
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Package-owned invariant companion. @module @luv1211/dsh-pet/invariant */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export declare const name = "pet-invariant";
|
|
5
|
+
/** Service required before the companion can reserve package ownership. */
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
/** Register this package's invariant companion. */
|
|
8
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
9
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Package-owned invariant companion. @module @luv1211/dsh-pet/invariant */
|
|
2
|
+
const PACKAGE_NAME = '@luv1211/dsh-pet';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export const name = 'pet-invariant';
|
|
5
|
+
/** Service required before the companion can reserve package ownership. */
|
|
6
|
+
export const inject = ['invariants'];
|
|
7
|
+
/**
|
|
8
|
+
* No runtime invariant: the preference document persists through the
|
|
9
|
+
* settings seam (schema-validated there), and activity records are
|
|
10
|
+
* presentation state the service derives and owns alone, so a companion
|
|
11
|
+
* would restate the service rather than check a relation another package
|
|
12
|
+
* can violate.
|
|
13
|
+
*/
|
|
14
|
+
const install = () => { };
|
|
15
|
+
/** Register this package's invariant companion. */
|
|
16
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
17
|
+
/* jscpd:ignore-end */
|
|
18
|
+
//# sourceMappingURL=invariant.js.map
|