@linxin666/dsh-pet 0.3.5 → 0.3.9
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.i18n.yaml +2 -2
- package/README.md +4 -0
- package/README.zh.md +4 -0
- package/contracts/voice-pack-v1.schema.json +30 -0
- package/cordis.patch.yml +1 -1
- package/lib/client.js +330 -103
- package/lib/client.js.map +1 -1
- package/lib/index.js +145 -28
- package/lib/invariant.js +1 -1
- package/lib/live2d-vendor.js +106 -95
- package/lib/live2d-vendor.js.map +1 -1
- package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
- package/lib/types/access.d.ts +5 -4
- package/lib/types/access.d.ts.map +1 -1
- package/lib/types/access.js +2 -12
- package/lib/types/affinity.d.ts +2 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +12 -0
- package/lib/types/announce.d.ts +53 -0
- package/lib/types/announce.d.ts.map +1 -0
- package/lib/types/announce.js +80 -0
- package/lib/types/client/PetDockEntry.d.ts +14 -5
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +6 -5
- package/lib/types/client/PetSettingsCard.d.ts +2 -1
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSprite.d.ts +8 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +36 -5
- package/lib/types/client/index.d.ts +8 -5
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +11 -5
- package/lib/types/client/locales.d.ts +20 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +20 -2
- package/lib/types/client/pet-store.d.ts +1 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +1 -1
- package/lib/types/client/renderers/frames2d.d.ts +12 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
- package/lib/types/client/renderers/frames2d.js +143 -18
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +10 -9
- package/lib/types/ledger.d.ts +4 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +4 -4
- package/lib/types/pair-access.d.ts +35 -0
- package/lib/types/pair-access.d.ts.map +1 -0
- package/lib/types/pair-access.js +19 -0
- package/lib/types/remarks.d.ts +1 -1
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +11 -2
- package/lib/types/service.d.ts +18 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +26 -1
- package/lib/types/voice-pack.d.ts +5 -0
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +36 -2
- package/package.json +17 -13
- package/src/access.ts +7 -27
- package/src/affinity.ts +13 -0
- package/src/announce.ts +103 -0
- package/src/client/PetDockEntry.test.tsx +1 -1
- package/src/client/PetDockEntry.tsx +15 -4
- package/src/client/PetSettingsCard.tsx +2 -1
- package/src/client/PetSprite.test.tsx +36 -0
- package/src/client/PetSprite.tsx +75 -4
- package/src/client/gameplay-hud.test.tsx +1 -1
- package/src/client/index.test.tsx +2 -2
- package/src/client/index.ts +17 -6
- package/src/client/locales.ts +20 -2
- package/src/client/pet-store.ts +2 -2
- package/src/client/pet.module.css +104 -0
- package/src/client/renderers/frames2d.test.ts +101 -0
- package/src/client/renderers/frames2d.ts +138 -18
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/index.ts +17 -15
- package/src/ledger.ts +6 -4
- package/src/pair-access.ts +49 -0
- package/src/remarks.test.ts +10 -0
- package/src/remarks.ts +9 -2
- package/src/service.ts +32 -2
- package/src/voice-pack.test.ts +39 -0
- package/src/voice-pack.ts +37 -2
package/lib/types/voice-pack.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* @module @linxin666/dsh-pet/voice-pack
|
|
29
29
|
*/
|
|
30
30
|
import { STATUS_SCENES, TOOL_CATEGORIES, WHISPER_CATEGORIES, WHISPER_RESULTS, } from "./chatter.js";
|
|
31
|
+
import { normalizePetRemarks } from "./remarks.js";
|
|
31
32
|
/** Schema version this module normalizes (optional field; missing = 1). */
|
|
32
33
|
export const VOICE_PACK_V1 = 1;
|
|
33
34
|
/** Hover-panel action buttons a pack can show or hide (canonical order). */
|
|
@@ -225,7 +226,7 @@ export function normalizePanel(raw, onWarning = () => { }) {
|
|
|
225
226
|
return panel;
|
|
226
227
|
}
|
|
227
228
|
/** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
|
|
228
|
-
export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel']);
|
|
229
|
+
export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel', 'remarks', 'ranks']);
|
|
229
230
|
/** Allowed whisper-section fields (drift-locked in tests). */
|
|
230
231
|
export const WHISPER_KEYS = new Set(['categories', 'results']);
|
|
231
232
|
/**
|
|
@@ -318,14 +319,37 @@ export function normalizeVoicePack(raw, onWarning = () => { }) {
|
|
|
318
319
|
}
|
|
319
320
|
}
|
|
320
321
|
const panel = raw.panel === undefined ? undefined : normalizePanel(raw.panel, onWarning);
|
|
322
|
+
const remarks = raw.remarks === undefined ? undefined : normalizePetRemarks(raw.remarks, onWarning);
|
|
323
|
+
const ranksRaw = raw.ranks;
|
|
324
|
+
let ranks;
|
|
325
|
+
if (ranksRaw !== undefined) {
|
|
326
|
+
if (!isRecord(ranksRaw)) {
|
|
327
|
+
onWarning('ranks must be an object');
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
const out = {};
|
|
331
|
+
for (const [key, value] of Object.entries(ranksRaw)) {
|
|
332
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
333
|
+
out[key] = value.trim().slice(0, VOICE_STAT_MAX);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
onWarning('invalid rank name for ' + key);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (Object.keys(out).length > 0)
|
|
340
|
+
ranks = out;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
321
343
|
if (overrides.status === undefined && overrides.tools === undefined
|
|
322
344
|
&& overrides.toolRemaining === undefined && overrides.whispers === undefined
|
|
323
|
-
&& panel === undefined) {
|
|
345
|
+
&& panel === undefined && remarks === undefined && ranks === undefined) {
|
|
324
346
|
return undefined;
|
|
325
347
|
}
|
|
326
348
|
return {
|
|
327
349
|
overrides,
|
|
328
350
|
...(panel === undefined ? {} : { panel }),
|
|
351
|
+
...(remarks === undefined ? {} : { remarks }),
|
|
352
|
+
...(ranks === undefined ? {} : { ranks }),
|
|
329
353
|
};
|
|
330
354
|
}
|
|
331
355
|
/**
|
|
@@ -338,6 +362,8 @@ export function mergeVoicePacks(...layers) {
|
|
|
338
362
|
const overrides = {};
|
|
339
363
|
const labels = {};
|
|
340
364
|
const stats = {};
|
|
365
|
+
let remarks;
|
|
366
|
+
let ranks;
|
|
341
367
|
let actions;
|
|
342
368
|
let panelSeen = false;
|
|
343
369
|
let any = false;
|
|
@@ -365,6 +391,12 @@ export function mergeVoicePacks(...layers) {
|
|
|
365
391
|
if (layer.panel.actions !== undefined)
|
|
366
392
|
actions = layer.panel.actions;
|
|
367
393
|
}
|
|
394
|
+
if (layer.remarks !== undefined) {
|
|
395
|
+
remarks = { ...(remarks ?? {}), ...layer.remarks };
|
|
396
|
+
}
|
|
397
|
+
if (layer.ranks !== undefined) {
|
|
398
|
+
ranks = { ...(ranks ?? {}), ...layer.ranks };
|
|
399
|
+
}
|
|
368
400
|
}
|
|
369
401
|
if (!any)
|
|
370
402
|
return undefined;
|
|
@@ -377,5 +409,7 @@ export function mergeVoicePacks(...layers) {
|
|
|
377
409
|
return {
|
|
378
410
|
overrides,
|
|
379
411
|
...(panelSeen && !panelEmpty ? { panel } : {}),
|
|
412
|
+
...(remarks !== undefined ? { remarks } : {}),
|
|
413
|
+
...(ranks !== undefined ? { ranks } : {}),
|
|
380
414
|
};
|
|
381
415
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-pet",
|
|
3
3
|
"description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -40,17 +40,17 @@
|
|
|
40
40
|
"license": "Apache-2.0",
|
|
41
41
|
"dsh": {
|
|
42
42
|
"engines": {
|
|
43
|
-
"dsh": ">=0.1.
|
|
43
|
+
"dsh": ">=0.1.2-alpha.1"
|
|
44
44
|
},
|
|
45
45
|
"bundle": {
|
|
46
46
|
"patch": "./cordis.patch.yml"
|
|
47
47
|
},
|
|
48
48
|
"client": {
|
|
49
49
|
"inject": [
|
|
50
|
-
"@deepseek-ai/dsh-client-runtime",
|
|
51
50
|
"@deepseek-ai/dsh-client-connection",
|
|
52
51
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
53
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
53
|
+
"@deepseek-ai/dsh-api-session-controller"
|
|
54
54
|
],
|
|
55
55
|
"platform": "web"
|
|
56
56
|
}
|
|
@@ -64,15 +64,19 @@
|
|
|
64
64
|
"react-dom": "^18.2.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
68
|
-
"@deepseek-ai/dsh-
|
|
69
|
-
"@deepseek-ai/dsh-
|
|
70
|
-
"@deepseek-ai/dsh-
|
|
71
|
-
"@deepseek-ai/dsh-client-
|
|
72
|
-
"@deepseek-ai/dsh-client-
|
|
73
|
-
"@deepseek-ai/dsh-
|
|
74
|
-
"@deepseek-ai/dsh-
|
|
75
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
67
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
68
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.2",
|
|
69
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.2-alpha.2",
|
|
70
|
+
"@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-alpha.2",
|
|
71
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
|
|
72
|
+
"@deepseek-ai/dsh-client-store": "^0.1.2-alpha.2",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.2",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
77
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
|
|
78
|
+
"@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
|
|
79
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
76
80
|
"@testing-library/dom": "^10.4.1",
|
|
77
81
|
"@testing-library/react": "^16.3.2",
|
|
78
82
|
"@types/node": "^22.20.0",
|
package/src/access.ts
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pet trust fence: loopback (the desktop) always passes; a live
|
|
3
|
-
* cookie is an additional allow path when remote-web-ui is
|
|
4
|
-
* never depends on that plugin — without the service the
|
|
5
|
-
* loopback-only
|
|
2
|
+
* Pet trust fence: loopback (the desktop) always passes; a live
|
|
3
|
+
* paired-device cookie is an additional allow path when remote-web-ui is
|
|
4
|
+
* loaded. The pet never depends on that plugin — without the service the
|
|
5
|
+
* fence stays loopback-only. The decision logic lives in the generated
|
|
6
|
+
* pair-access.ts copy (shared by git-graph / pet / skill-explorer).
|
|
6
7
|
*/
|
|
7
8
|
import type { IncomingMessage } from 'node:http'
|
|
8
9
|
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
|
12
|
-
interface PairingAccess {
|
|
13
|
-
isPairedDevice(request: IncomingMessage): boolean
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** ctx.get is optional on the test harness; production Context always has it. */
|
|
17
|
-
type LookupCtx = Context & {
|
|
18
|
-
get?(name: string, strict?: boolean): unknown
|
|
19
|
-
remoteWebUiPairing?: PairingAccess
|
|
20
|
-
}
|
|
10
|
+
import { isPairedOrLoopbackAllowed } from './pair-access.ts'
|
|
21
11
|
|
|
22
12
|
/**
|
|
23
13
|
* Whether this request may enter any /api/pet or /pet asset route.
|
|
@@ -26,15 +16,5 @@ type LookupCtx = Context & {
|
|
|
26
16
|
* @returns true for loopback, or a live paired-device cookie.
|
|
27
17
|
*/
|
|
28
18
|
export function isPetAllowed(ctx: Context, request: IncomingMessage): boolean {
|
|
29
|
-
|
|
30
|
-
const bag = ctx as LookupCtx
|
|
31
|
-
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
|
32
|
-
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
|
33
|
-
return pairing?.isPairedDevice(request) === true
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function isPairingAccess(value: unknown): value is PairingAccess {
|
|
37
|
-
return value !== undefined
|
|
38
|
-
&& value !== null
|
|
39
|
-
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
|
19
|
+
return isPairedOrLoopbackAllowed(ctx, request)
|
|
40
20
|
}
|
package/src/affinity.ts
CHANGED
|
@@ -56,6 +56,19 @@ export const AFFINITY_RANKS = [
|
|
|
56
56
|
{ min: 100_000, name: '鲸生共渡', emoji: '*********' },
|
|
57
57
|
] as const
|
|
58
58
|
|
|
59
|
+
/** English translations for the 9 built-in affinity rank names (#1226). */
|
|
60
|
+
export const AFFINITY_RANKS_EN: Readonly<Record<string, string>> = {
|
|
61
|
+
'幼鲸': 'Baby Whale',
|
|
62
|
+
'伙伴': 'Companion',
|
|
63
|
+
'挚友': 'Close Friend',
|
|
64
|
+
'深海羁绊': 'Deep Sea Bond',
|
|
65
|
+
'心有灵犀': 'Kindred Spirit',
|
|
66
|
+
'传说羁绊': 'Legendary Bond',
|
|
67
|
+
'神话羁绊': 'Mythic Bond',
|
|
68
|
+
'永恒之契': 'Eternal Covenant',
|
|
69
|
+
'鲸生共渡': 'Lifelong Companion',
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
/** Interaction tuning (all in points / ms). */
|
|
60
73
|
export interface AffinityConfig {
|
|
61
74
|
/** Points per completed turn. */
|
package/src/announce.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pet announcement-bubble contract (dsh-usage linkage): a sibling plugin
|
|
3
|
+
* pushes one structured announcement through `pet.announce(...)`, the host
|
|
4
|
+
* validates it into a bounded payload, and the browser half renders it as a
|
|
5
|
+
* dedicated, specially styled bubble above the session bubble stack.
|
|
6
|
+
*
|
|
7
|
+
* The validation lives in this pure module so the wire contract has exactly
|
|
8
|
+
* one home and stays testable without the cordis service.
|
|
9
|
+
* @module @linxin666/dsh-pet/announce
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One plugin-authored announcement bubble. */
|
|
13
|
+
export interface PetAnnouncement {
|
|
14
|
+
/** Authoring plugin's source tag (`dsh-usage` for the usage statistics). */
|
|
15
|
+
source: string
|
|
16
|
+
/** Bubble content kind: a spend estimate, an account balance, or a plan-quota status. */
|
|
17
|
+
kind: 'balance' | 'cost' | 'plan'
|
|
18
|
+
/** Lead text (usually the provider display name). */
|
|
19
|
+
title: string
|
|
20
|
+
/** Balance or today-spend amount, formatted for display (kinds `balance` and `cost`). */
|
|
21
|
+
amount?: string
|
|
22
|
+
/** Plan usage percent 0-100 (kind `plan`). */
|
|
23
|
+
percent?: number
|
|
24
|
+
/** ISO 8601 reset instant (kind `plan`). */
|
|
25
|
+
resetAt?: string
|
|
26
|
+
/** Short trailing note (plan tier name, peak-period status, currency code, ...). */
|
|
27
|
+
note?: string
|
|
28
|
+
/** Visual tone; drives the bubble's accent color. */
|
|
29
|
+
tone: 'ok' | 'warn' | 'low'
|
|
30
|
+
/** Freshness window in ms; an expired announcement stops rendering. */
|
|
31
|
+
ttlMs: number
|
|
32
|
+
/** Epoch ms the announcement arrived. */
|
|
33
|
+
at: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Default freshness window. */
|
|
37
|
+
export const ANNOUNCE_DEFAULT_TTL_MS = 10_000
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hard TTL ceiling. A repeating announcer (dsh-usage) declares its poll
|
|
41
|
+
* cadence as the TTL, so an `always`-mode bubble stays continuous across
|
|
42
|
+
* polls; the ceiling means a source that dies unmounts its bubble within at
|
|
43
|
+
* most one missed refresh cycle rather than lingering forever.
|
|
44
|
+
*/
|
|
45
|
+
export const ANNOUNCE_MAX_TTL_MS = 7_200_000
|
|
46
|
+
|
|
47
|
+
/** Hard bounds: lengths and the TTL range (1 s .. 60 s). */
|
|
48
|
+
const TITLE_MAX = 80
|
|
49
|
+
const TEXT_MAX = 120
|
|
50
|
+
const SOURCE_MAX = 64
|
|
51
|
+
|
|
52
|
+
function boundedString(value: unknown, max: number): string | undefined {
|
|
53
|
+
if (typeof value !== 'string') return undefined
|
|
54
|
+
const trimmed = value.trim()
|
|
55
|
+
if (trimmed === '') return undefined
|
|
56
|
+
return trimmed.length <= max ? trimmed : trimmed.slice(0, max)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate one announce payload. Unknown fields are dropped, oversized text
|
|
61
|
+
* is truncated, and anything structurally wrong resolves to undefined — a
|
|
62
|
+
* malformed announcement never reaches the pet's bubble surface.
|
|
63
|
+
* @param input - the payload a sibling plugin passed to `pet.announce`.
|
|
64
|
+
* @param now - epoch ms the announcement arrived.
|
|
65
|
+
*/
|
|
66
|
+
export function parseAnnouncement(input: unknown, now: number): PetAnnouncement | undefined {
|
|
67
|
+
if (typeof input !== 'object' || input === null) return undefined
|
|
68
|
+
const data = input as Record<string, unknown>
|
|
69
|
+
const source = boundedString(data.source, SOURCE_MAX)
|
|
70
|
+
const title = boundedString(data.title, TITLE_MAX)
|
|
71
|
+
if (source === undefined || title === undefined) return undefined
|
|
72
|
+
const kind = data.kind === 'balance' || data.kind === 'cost' || data.kind === 'plan' ? data.kind : undefined
|
|
73
|
+
if (kind === undefined) return undefined
|
|
74
|
+
const amount = boundedString(data.amount, TEXT_MAX)
|
|
75
|
+
const resetAt = boundedString(data.resetAt, TEXT_MAX)
|
|
76
|
+
const note = boundedString(data.note, TITLE_MAX)
|
|
77
|
+
const percent = typeof data.percent === 'number' && Number.isFinite(data.percent)
|
|
78
|
+
? Math.max(0, Math.min(100, data.percent))
|
|
79
|
+
: undefined
|
|
80
|
+
if ((kind === 'balance' || kind === 'cost') && amount === undefined) return undefined
|
|
81
|
+
if (kind === 'plan' && percent === undefined) return undefined
|
|
82
|
+
const tone = data.tone === 'warn' || data.tone === 'low' ? data.tone : 'ok'
|
|
83
|
+
const ttlMs = typeof data.ttlMs === 'number' && Number.isFinite(data.ttlMs)
|
|
84
|
+
? Math.max(1000, Math.min(ANNOUNCE_MAX_TTL_MS, data.ttlMs))
|
|
85
|
+
: ANNOUNCE_DEFAULT_TTL_MS
|
|
86
|
+
return {
|
|
87
|
+
source,
|
|
88
|
+
kind,
|
|
89
|
+
title,
|
|
90
|
+
...(amount !== undefined ? { amount } : {}),
|
|
91
|
+
...(percent !== undefined ? { percent } : {}),
|
|
92
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
93
|
+
...(note !== undefined ? { note } : {}),
|
|
94
|
+
tone,
|
|
95
|
+
ttlMs,
|
|
96
|
+
at: now,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether an announcement is still fresh at `now`. */
|
|
101
|
+
export function announcementFresh(announcement: PetAnnouncement, now: number): boolean {
|
|
102
|
+
return now - announcement.at < announcement.ttlMs
|
|
103
|
+
}
|
|
@@ -9,7 +9,7 @@ import { cleanup, render, screen } from '@testing-library/react'
|
|
|
9
9
|
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
10
10
|
// __ModuleLoader__ (not importable under vitest); provide the defineStore
|
|
11
11
|
// the pet store needs (same fake-store pattern as the settings-card tests).
|
|
12
|
-
vi.mock('@deepseek-ai/dsh-client-
|
|
12
|
+
vi.mock('@deepseek-ai/dsh-client-store', () => ({
|
|
13
13
|
defineStore: (spec: {
|
|
14
14
|
init: () => unknown
|
|
15
15
|
actions: Record<string, (draft: never, ...args: never[]) => void>
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* it must not ride a session-scoped slot — on the new-conversation screen no
|
|
5
5
|
* session exists to scope a slot by, and the pet would vanish (issue #48).
|
|
6
6
|
* The client half therefore mounts this entry straight onto 'document.body'
|
|
7
|
-
* (see index.ts): while visible it renders the floating PetSprite (a
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the registry list — no per-pet
|
|
7
|
+
* (see index.ts): while visible it renders the floating PetSprite (a portal
|
|
8
|
+
* into the plugin root, so the root owns the whole surface), while hidden it
|
|
9
|
+
* renders a fixed-position summon button. Which sprite renders is decided by
|
|
10
|
+
* the host snapshot's pet id resolved against the registry list — no per-pet
|
|
11
|
+
* component exists.
|
|
11
12
|
* @module @linxin666/dsh-pet/client/PetDockEntry
|
|
12
13
|
*/
|
|
13
14
|
|
|
@@ -52,6 +53,15 @@ export interface PetInjected {
|
|
|
52
53
|
export type PetDockEntryProps =
|
|
53
54
|
PetInjected
|
|
54
55
|
& PropsLocale<typeof NS>
|
|
56
|
+
& {
|
|
57
|
+
/**
|
|
58
|
+
* DOM node the floating sprite chrome portals into. Defaults to
|
|
59
|
+
* document.body; the plugin apply passes its [data-dsh-plugin="pet"]
|
|
60
|
+
* root so root-keyed suppressors (the portrait mobile layer) and skins
|
|
61
|
+
* own the whole pet surface as one unit.
|
|
62
|
+
*/
|
|
63
|
+
portalTarget?: Element
|
|
64
|
+
}
|
|
55
65
|
|
|
56
66
|
const DEFAULT_DISPLAY: PetDisplayConfig = { visible: true, size: 160, right: 24, bottom: 20 }
|
|
57
67
|
|
|
@@ -108,6 +118,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
|
|
|
108
118
|
onRename={props.rename}
|
|
109
119
|
onOpenSession={props.openSession}
|
|
110
120
|
onFeedbackDone={props.feedbackDone}
|
|
121
|
+
portalTarget={props.portalTarget}
|
|
111
122
|
dragDisabled={snapshot.gameplay?.mode === 'work'}
|
|
112
123
|
{...(gameplay === undefined || aux === null
|
|
113
124
|
? {}
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ReactNode } from 'react'
|
|
11
11
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
12
|
-
import type { SettingsScope
|
|
12
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
13
|
+
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
14
|
// Type-only: pulls the settings-surface SlotMap merge (the 'settings.section' entry).
|
|
14
15
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
15
16
|
import { PluginSettingsCard, ValueField, BooleanField, ChoiceField } from './PluginSettingsCard.tsx'
|
|
@@ -176,6 +176,24 @@ describe('PetSprite hide control (#1126)', () => {
|
|
|
176
176
|
})
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
+
describe('PetSprite affinity rank localization (#1226)', () => {
|
|
180
|
+
it('localizes the rank name in the hover panel when document language is en', () => {
|
|
181
|
+
document.documentElement.lang = 'en'
|
|
182
|
+
try {
|
|
183
|
+
renderPet({
|
|
184
|
+
snapshot: {
|
|
185
|
+
...snapshot,
|
|
186
|
+
affinity: { ...snapshot.affinity, rank: '幼鲸' },
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
190
|
+
expect(screen.getByText('Affinity Baby Whale')).toBeTruthy()
|
|
191
|
+
} finally {
|
|
192
|
+
document.documentElement.lang = 'zh'
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
179
197
|
describe('PetSprite rename input', () => {
|
|
180
198
|
it('submits the draft on Enter outside composition', () => {
|
|
181
199
|
const { onRename } = renderPet()
|
|
@@ -1104,3 +1122,21 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
1104
1122
|
expect(timerSpy.mock.calls.length).toBe(schedulesBefore + 1)
|
|
1105
1123
|
})
|
|
1106
1124
|
})
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
describe('PetSprite portal target', () => {
|
|
1128
|
+
it('portals the float into the provided target instead of document.body', () => {
|
|
1129
|
+
const host = document.createElement('div')
|
|
1130
|
+
document.body.appendChild(host)
|
|
1131
|
+
renderPet({ portalTarget: host })
|
|
1132
|
+
// The float (sprite chrome) lands inside the owning root, so a
|
|
1133
|
+
// root-keyed suppressor (the portrait mobile layer) hides it as one unit.
|
|
1134
|
+
expect(host.querySelector('[role="button"]')).not.toBeNull()
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
it('portals the float into document.body when no target is given', () => {
|
|
1138
|
+
renderPet()
|
|
1139
|
+
expect(document.body.querySelector('[role="button"]')).not.toBeNull()
|
|
1140
|
+
})
|
|
1141
|
+
})
|
|
1142
|
+
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import clsx from 'clsx'
|
|
|
16
16
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
17
17
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
18
18
|
import type { PetStateView } from '../service.ts'
|
|
19
|
+
import { announcementFresh, type PetAnnouncement } from '../announce.ts'
|
|
19
20
|
import type { PetDefinition } from '../registry.ts'
|
|
20
21
|
import type { DecorationView } from '../contracts/status-decoration.ts'
|
|
21
22
|
import type { PetFeedback } from './pet-store.ts'
|
|
@@ -78,6 +79,14 @@ export interface PetSpriteProps {
|
|
|
78
79
|
onGameplayMenu?: () => void
|
|
79
80
|
/** Disable the drag gesture (gameplay work mode blocks dragging). */
|
|
80
81
|
dragDisabled?: boolean
|
|
82
|
+
/**
|
|
83
|
+
* DOM node the floating chrome portals into. Defaults to document.body
|
|
84
|
+
* (the legacy behavior); the plugin apply passes its owning root (the
|
|
85
|
+
* [data-dsh-plugin="pet"] container) so the root owns the whole surface
|
|
86
|
+
* and a root-keyed suppressor (the portrait mobile layer) hides the pet
|
|
87
|
+
* as one unit instead of missing the portaled sprite.
|
|
88
|
+
*/
|
|
89
|
+
portalTarget?: Element
|
|
81
90
|
/** Locale translate seat (namespace-bound). */
|
|
82
91
|
t: TranslateNS<typeof NS>
|
|
83
92
|
}
|
|
@@ -176,6 +185,52 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
|
|
|
176
185
|
)
|
|
177
186
|
}
|
|
178
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The announcement bubble (dsh-usage linkage): a dedicated, specially
|
|
190
|
+
* designed surface for sibling-plugin facts — a balance or today-spend pill,
|
|
191
|
+
* or a plan-quota card with a tone-tinted accent, a mini meter for percent
|
|
192
|
+
* windows, and the reset instant. It rides the top of the session bubble
|
|
193
|
+
* stack (column-reverse puts the DOM-last child farthest from the sprite)
|
|
194
|
+
* and persists for its TTL instead of the short feedback pop.
|
|
195
|
+
*/
|
|
196
|
+
function UsageAnnouncementBubble(props: { announcement: PetAnnouncement }): ReactNode {
|
|
197
|
+
const { announcement } = props
|
|
198
|
+
const tone = announcement.tone === 'low'
|
|
199
|
+
? styles.bubbleUsageLow
|
|
200
|
+
: announcement.tone === 'warn'
|
|
201
|
+
? styles.bubbleUsageWarn
|
|
202
|
+
: styles.bubbleUsageOk
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
className={clsx(styles.bubble, styles.bubbleUsage, tone)}
|
|
206
|
+
role="status"
|
|
207
|
+
aria-live="polite"
|
|
208
|
+
data-dsh-pet-announcement={announcement.source}
|
|
209
|
+
>
|
|
210
|
+
<span className={styles.bubbleUsageHead}>
|
|
211
|
+
<span className={styles.bubbleUsageTitle}>{announcement.title}</span>
|
|
212
|
+
{(announcement.kind === 'balance' || announcement.kind === 'cost') && announcement.amount !== undefined && (
|
|
213
|
+
<span className={styles.bubbleUsageValue}>{announcement.amount}</span>
|
|
214
|
+
)}
|
|
215
|
+
{announcement.kind === 'plan' && announcement.percent !== undefined && (
|
|
216
|
+
<span className={styles.bubbleUsageValue}>{Math.round(announcement.percent) + '%'}</span>
|
|
217
|
+
)}
|
|
218
|
+
</span>
|
|
219
|
+
{announcement.kind === 'plan' && announcement.percent !== undefined && (
|
|
220
|
+
<span className={styles.bubbleUsageMeter}>
|
|
221
|
+
<span
|
|
222
|
+
className={styles.bubbleUsageMeterFill}
|
|
223
|
+
style={{ width: Math.min(100, Math.max(0, announcement.percent)) + '%' }}
|
|
224
|
+
/>
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
{announcement.note !== undefined && (
|
|
228
|
+
<span className={styles.bubbleUsageNote}>{announcement.note}</span>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
179
234
|
/**
|
|
180
235
|
* The floating pet. The spritesheet frame advances on requestAnimationFrame
|
|
181
236
|
* with per-frame durations from the definition's tracks; the atlas image is
|
|
@@ -432,6 +487,13 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
432
487
|
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
433
488
|
? snapshot?.bubble
|
|
434
489
|
: undefined
|
|
490
|
+
// The freshest plugin-authored announcement (dsh-usage linkage): a
|
|
491
|
+
// dedicated, specially styled bubble above the session stack. The host
|
|
492
|
+
// already TTL-filters; this client-side check covers the last poll tick.
|
|
493
|
+
const announcement = snapshot?.announcement
|
|
494
|
+
const usageAnnouncement = feedback === null && announcement !== undefined && announcementFresh(announcement, Date.now())
|
|
495
|
+
? announcement
|
|
496
|
+
: undefined
|
|
435
497
|
// Each session's inner whisper (碎碎念) rides its own bubble — short
|
|
436
498
|
// inner-voice copy woken by that session's activity, never the model's or
|
|
437
499
|
// another session's. Instead of a second bubble of its own, a fresh
|
|
@@ -439,7 +501,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
439
501
|
// never wears two voices at once. Interaction feedback takes over the
|
|
440
502
|
// whole bubble area while it plays, so whispers yield to it like status
|
|
441
503
|
// copy.
|
|
442
|
-
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
|
|
504
|
+
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || usageAnnouncement !== undefined
|
|
443
505
|
const displayName = snapshot?.name ?? definition.displayName
|
|
444
506
|
// The host-served status decoration (M5, #567); absent = text-only bubbles.
|
|
445
507
|
const decoration = snapshot?.decoration
|
|
@@ -549,7 +611,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
549
611
|
{feedback.text}
|
|
550
612
|
</div>
|
|
551
613
|
)}
|
|
552
|
-
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined) && (
|
|
614
|
+
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || usageAnnouncement !== undefined) && (
|
|
553
615
|
<div
|
|
554
616
|
ref={bubbleRef}
|
|
555
617
|
className={styles.bubbleStack}
|
|
@@ -620,6 +682,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
620
682
|
{statusBubble}
|
|
621
683
|
</div>
|
|
622
684
|
)}
|
|
685
|
+
{usageAnnouncement !== undefined && <UsageAnnouncementBubble announcement={usageAnnouncement} />}
|
|
623
686
|
</div>
|
|
624
687
|
)}
|
|
625
688
|
{hovered && dragRef.current === null && (
|
|
@@ -685,7 +748,15 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
685
748
|
<>
|
|
686
749
|
<div className={styles.rankRow}>
|
|
687
750
|
<span className={styles.nameCell}>{displayName}</span>
|
|
688
|
-
<span className={styles.statRank}>
|
|
751
|
+
<span className={styles.statRank}>
|
|
752
|
+
{(() => {
|
|
753
|
+
const rawRank = snapshot?.affinity.rank ?? '?'
|
|
754
|
+
const rankKey = `pet.rank.name.${rawRank}`
|
|
755
|
+
const localized = props.t(rankKey as any)
|
|
756
|
+
const rankName = localized !== rankKey ? localized : rawRank
|
|
757
|
+
return panelStat('rank', 'pet.rank', { rank: rankName })
|
|
758
|
+
})()}
|
|
759
|
+
</span>
|
|
689
760
|
</div>
|
|
690
761
|
<div className={styles.rankRow}>
|
|
691
762
|
<span className={styles.statTreats}>{panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
@@ -730,5 +801,5 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
730
801
|
</div>
|
|
731
802
|
)
|
|
732
803
|
|
|
733
|
-
return createPortal(float, document.body)
|
|
804
|
+
return createPortal(float, props.portalTarget ?? document.body)
|
|
734
805
|
}
|
|
@@ -9,7 +9,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
|
9
9
|
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
10
10
|
// __ModuleLoader__ (not importable under vitest); provide the defineStore
|
|
11
11
|
// the pet store needs (same fake-store pattern as PetDockEntry.test.tsx).
|
|
12
|
-
vi.mock('@deepseek-ai/dsh-client-
|
|
12
|
+
vi.mock('@deepseek-ai/dsh-client-store', () => ({
|
|
13
13
|
defineStore: (spec: {
|
|
14
14
|
init: () => unknown
|
|
15
15
|
actions: Record<string, (draft: never, ...args: never[]) => void>
|
|
@@ -12,7 +12,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
12
12
|
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
13
13
|
// __ModuleLoader__ (not importable under vitest); provide defineStore /
|
|
14
14
|
// createSnapshotStore (same fake-store pattern as the settings-card tests).
|
|
15
|
-
vi.mock('@deepseek-ai/dsh-client-
|
|
15
|
+
vi.mock('@deepseek-ai/dsh-client-store', () => ({
|
|
16
16
|
defineStore: (spec: {
|
|
17
17
|
init: () => unknown
|
|
18
18
|
actions: Record<string, (draft: never, ...args: never[]) => void>
|
|
@@ -48,7 +48,7 @@ vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
}))
|
|
51
|
-
import type { ClientContext } from '@deepseek-ai/
|
|
51
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
52
52
|
import { apply } from './index.ts'
|
|
53
53
|
|
|
54
54
|
beforeAll(() => {
|
package/src/client/index.ts
CHANGED
|
@@ -4,19 +4,26 @@
|
|
|
4
4
|
* endpoints: fetch the registry list once, poll the host snapshot (~2 s),
|
|
5
5
|
* forward interactions, persist drag positions. The pet is host-global (no
|
|
6
6
|
* session dimension), so it mounts directly onto 'document.body' via a
|
|
7
|
-
* single React root
|
|
8
|
-
* new-conversation screen no session exists, and
|
|
9
|
-
* vanish there (issue #48).
|
|
10
|
-
*
|
|
7
|
+
* single React root — the [data-dsh-plugin="pet"] container — rather than a
|
|
8
|
+
* session-scoped slot: on the new-conversation screen no session exists, and
|
|
9
|
+
* a dock-mounted pet would vanish there (issue #48). The sprite chrome
|
|
10
|
+
* portals into that same root, so the root owns the whole surface and a
|
|
11
|
+
* root-keyed suppressor can hide it as one unit. When the pet is hidden the
|
|
12
|
+
* entry becomes a fixed-position summon button.
|
|
11
13
|
* @module @linxin666/dsh-pet/client
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
import type {
|
|
16
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
17
|
+
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
18
|
+
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
19
|
+
import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
15
20
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
16
21
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
17
22
|
// Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
|
|
18
23
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
19
24
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
25
|
+
// Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
20
27
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
21
28
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
22
29
|
import type { PetGameplayVerbResult, PetInteractResult, PetStateView } from '../service.ts'
|
|
@@ -381,7 +388,11 @@ export function apply(ctx: ClientContext): void {
|
|
|
381
388
|
container.dataset.dshPlugin = 'pet'
|
|
382
389
|
document.body.appendChild(container)
|
|
383
390
|
const petRoot = createRoot(container)
|
|
384
|
-
|
|
391
|
+
// The sprite chrome portals into THIS root (not document.body): the
|
|
392
|
+
// root then owns the whole surface, so a root-keyed suppressor (the
|
|
393
|
+
// portrait mobile layer, which hides [data-dsh-plugin="pet"]) really
|
|
394
|
+
// hides the sprite instead of missing the portaled float.
|
|
395
|
+
petRoot.render(createElement(PetDockEntry, { ...injected(), t, portalTarget: container }))
|
|
385
396
|
|
|
386
397
|
let uiGone = false
|
|
387
398
|
disposeUi = () => {
|