@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
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* card-store pattern.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { SettingsScope, SettingsScopeSnapshot
|
|
12
|
-
import {
|
|
11
|
+
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
12
|
+
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
|
+
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
14
|
|
|
14
15
|
/** The write one field's staged text performs when the card is saved. */
|
|
15
16
|
export type FieldWrite =
|
|
@@ -23,9 +24,11 @@ export interface FieldSpec {
|
|
|
23
24
|
/**
|
|
24
25
|
* Whether the Host treats this field as a secret and redacts its value from
|
|
25
26
|
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
26
|
-
* never compared against the draft on save
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* never compared against the draft on save: the Host strips them from every
|
|
28
|
+
* wire view layer, so the settled snapshot carries nothing to read back. A
|
|
29
|
+
* staged secret set is judged by the mutation settling; the rest of its
|
|
30
|
+
* batch, when one exists, is still judged by read-back, and the atomic
|
|
31
|
+
* mutation lands every write or none.
|
|
29
32
|
*/
|
|
30
33
|
secret?: boolean
|
|
31
34
|
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
@@ -53,8 +56,8 @@ export interface CardShell {
|
|
|
53
56
|
available: boolean
|
|
54
57
|
/**
|
|
55
58
|
* Whether the namespace is actually served to this client. False when the
|
|
56
|
-
* Host deployment does not expose it (e.g. the
|
|
57
|
-
*
|
|
59
|
+
* Host deployment does not expose it (e.g. the owning plugin's settings
|
|
60
|
+
* domain is not mounted): the card renders an explanation
|
|
58
61
|
* instead of its form, so a missing namespace never looks like a missing
|
|
59
62
|
* plugin.
|
|
60
63
|
*/
|
|
@@ -101,13 +104,17 @@ interface StagedEdit {
|
|
|
101
104
|
interface PlannedWrite {
|
|
102
105
|
/** Field this entry writes. */
|
|
103
106
|
field: string
|
|
104
|
-
/** The durable write this entry performs,
|
|
107
|
+
/** The durable write this entry performs, inside the save's one atomic mutation. */
|
|
105
108
|
op: BatchedWrite
|
|
106
|
-
/**
|
|
107
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Read the settled snapshot back and report whether the Host holds this
|
|
111
|
+
* write's effect. Undefined when the draft is not a value the field
|
|
112
|
+
* accepts: there is nothing to write, and the entry blocks the save.
|
|
113
|
+
*/
|
|
114
|
+
judge: (() => boolean) | undefined
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
/** One durable write
|
|
117
|
+
/** One durable write inside the save's atomic scope mutation. */
|
|
111
118
|
export interface BatchedWrite {
|
|
112
119
|
/** Field this entry writes. */
|
|
113
120
|
field: string
|
|
@@ -117,36 +124,6 @@ export interface BatchedWrite {
|
|
|
117
124
|
value?: unknown
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
/** Per-field outcome of one batched scope write. */
|
|
121
|
-
export interface BatchedFieldResult {
|
|
122
|
-
/** Field this entry writes. */
|
|
123
|
-
field: string
|
|
124
|
-
/** Whether the Host accepted this field's write (per the read-back view). */
|
|
125
|
-
landed: boolean
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Result of a batched scope write. The bridge scope posts every planned write
|
|
130
|
-
* in one /mutate so the Host validate hook judges baseURL+model together; a
|
|
131
|
-
* batched refusal fails the whole save rather than per-field.
|
|
132
|
-
*/
|
|
133
|
-
export interface BatchResult {
|
|
134
|
-
/** Whether the whole mutate was accepted. */
|
|
135
|
-
ok: boolean
|
|
136
|
-
/** Per-field success, in the request order (always present when ok). */
|
|
137
|
-
fields: BatchedFieldResult[]
|
|
138
|
-
/** Host rejection code (mutate refused). */
|
|
139
|
-
code?: string
|
|
140
|
-
/** Host rejection message (mutate refused). */
|
|
141
|
-
message?: string
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** The optional batch surface the bridge scope adds over the SettingsScope contract. */
|
|
145
|
-
interface BatchedSettingsScope {
|
|
146
|
-
/** Write every operation in one scope mutation, reporting per-field success. */
|
|
147
|
-
mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
|
|
148
|
-
}
|
|
149
|
-
|
|
150
127
|
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
151
128
|
export interface NumberConstraints {
|
|
152
129
|
/** The accepted value must be a whole number. */
|
|
@@ -188,8 +165,8 @@ export function textField(field: string): FieldSpec {
|
|
|
188
165
|
/**
|
|
189
166
|
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
190
167
|
* (role('secret') in the section schema). The card still edits it like text,
|
|
191
|
-
* but a save never compares the redacted value back
|
|
192
|
-
*
|
|
168
|
+
* but a save never compares the redacted value back: the staged set is judged
|
|
169
|
+
* by the mutation settling (see {@link FieldSpec.secret}).
|
|
193
170
|
*/
|
|
194
171
|
export function secretField(field: string): FieldSpec {
|
|
195
172
|
return { ...textField(field), secret: true }
|
|
@@ -277,7 +254,7 @@ export class CardForm<T> {
|
|
|
277
254
|
exposed: snapshot.status === 'ready',
|
|
278
255
|
writable: snapshot.writable,
|
|
279
256
|
dirty: plan.length > 0,
|
|
280
|
-
invalid: plan.some(item => item.
|
|
257
|
+
invalid: plan.some(item => item.judge === undefined),
|
|
281
258
|
saving: this.saving,
|
|
282
259
|
failed: this.failed,
|
|
283
260
|
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
@@ -318,21 +295,25 @@ export class CardForm<T> {
|
|
|
318
295
|
}
|
|
319
296
|
|
|
320
297
|
/**
|
|
321
|
-
* Write every staged edit, then re-seed from
|
|
298
|
+
* Write every staged edit in one atomic scope mutation, then re-seed from
|
|
299
|
+
* what the Host accepted.
|
|
322
300
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
301
|
+
* The whole batch rides one mutate, so cross-field validate hooks
|
|
302
|
+
* (baseURL+model) judge it as a unit: the Host either applies every write
|
|
303
|
+
* or refuses the batch. The 0.1.2 scope contract never rejects a refused
|
|
304
|
+
* mutation — the scope recovers with a fresh Host view and resolves — so
|
|
305
|
+
* resolution alone proves nothing: the outcome is judged by reading the
|
|
306
|
+
* settled snapshot back, one planned write at a time, and one missed write
|
|
307
|
+
* fails the whole save. A scope that still rejects on refusal (the dsh-web
|
|
308
|
+
* bridge scope) reports through the same failure path with its rejection
|
|
309
|
+
* message. A save that did not land keeps its drafts, so the user can
|
|
310
|
+
* correct them instead of retyping.
|
|
311
|
+
* @returns settlement after the mutation and the read-back.
|
|
330
312
|
*/
|
|
331
313
|
async save(): Promise<void> {
|
|
332
314
|
const plan = this.plan()
|
|
333
|
-
const valid = plan.filter(item => item.
|
|
315
|
+
const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
|
|
334
316
|
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
335
|
-
const plannedWrites = valid.map(item => item.op)
|
|
336
317
|
// Snapshot the staged entries this save writes, so an edit staged while it
|
|
337
318
|
// is in flight (which replaces the same key) survives: only delete the key
|
|
338
319
|
// when the entry is still the one this save started from.
|
|
@@ -342,36 +323,34 @@ export class CardForm<T> {
|
|
|
342
323
|
this.failed = false
|
|
343
324
|
this.failedReason = undefined
|
|
344
325
|
this.publish()
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
} else {
|
|
357
|
-
for (const item of valid) {
|
|
358
|
-
if (await item.run!()) landed.add(item.field)
|
|
359
|
-
}
|
|
326
|
+
// One atomic namespace mutation: the 0.1.2 scope contract takes ordered
|
|
327
|
+
// path operations, so the whole staged batch is validated, persisted, and
|
|
328
|
+
// recovered together — either every write lands or none does.
|
|
329
|
+
const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
|
|
330
|
+
? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
|
|
331
|
+
: { op: 'unset', path: [item.field] })
|
|
332
|
+
let failedReason: string | undefined
|
|
333
|
+
try {
|
|
334
|
+
await this.scope.mutate(ops)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
failedReason = error instanceof Error ? error.message : String(error)
|
|
360
337
|
}
|
|
338
|
+
// The 0.1.2 scope resolves even a refused mutation (it recovers with a
|
|
339
|
+
// fresh view instead of throwing), so resolution alone proves nothing:
|
|
340
|
+
// judge every planned write against the settled snapshot. The mutation is
|
|
341
|
+
// atomic, so one missed write fails the whole save and keeps the drafts.
|
|
342
|
+
const landed = failedReason === undefined && valid.every(item => item.judge())
|
|
361
343
|
for (const [field, before] of pending) {
|
|
362
|
-
if (landed
|
|
344
|
+
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
363
345
|
}
|
|
364
346
|
this.saving = false
|
|
365
|
-
this.failed = landed
|
|
347
|
+
this.failed = !landed
|
|
348
|
+
// A read-back failure carries no server reason: the card surfaces its
|
|
349
|
+
// generic failure copy; a rejecting scope (the bridge) adds its message.
|
|
350
|
+
this.failedReason = failedReason
|
|
366
351
|
this.publish()
|
|
367
352
|
}
|
|
368
353
|
|
|
369
|
-
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
370
|
-
private batchedScope(): BatchedSettingsScope | undefined {
|
|
371
|
-
const candidate = this.scope as unknown as BatchedSettingsScope | undefined
|
|
372
|
-
return typeof candidate?.mutate === 'function' ? candidate : undefined
|
|
373
|
-
}
|
|
374
|
-
|
|
375
354
|
/**
|
|
376
355
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
377
356
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -384,33 +363,40 @@ export class CardForm<T> {
|
|
|
384
363
|
for (const [field, staged] of this.staged) {
|
|
385
364
|
const spec = this.specOf(field)
|
|
386
365
|
if (staged.clear) {
|
|
387
|
-
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' },
|
|
366
|
+
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
|
|
388
367
|
continue
|
|
389
368
|
}
|
|
390
369
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
391
370
|
const write = spec.parse(staged.text)
|
|
392
|
-
if (write === undefined) plan.push({ field, op: { field, op: 'unset' },
|
|
393
|
-
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' },
|
|
394
|
-
else plan.push({ field, op: { field, op: 'set', value: write.value },
|
|
371
|
+
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, judge: undefined })
|
|
372
|
+
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
|
|
373
|
+
else plan.push({ field, op: { field, op: 'set', value: write.value }, judge: () => this.landedSet(field, write.value) })
|
|
395
374
|
}
|
|
396
375
|
return plan
|
|
397
376
|
}
|
|
398
377
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
// the per-field path the scope resolved, so the write is landed.
|
|
378
|
+
/**
|
|
379
|
+
* Read-back judgment for a planned set: the user layer must hold the
|
|
380
|
+
* intended value once the mutation has settled.
|
|
381
|
+
*/
|
|
382
|
+
private landedSet(field: string, value: unknown): boolean {
|
|
383
|
+
// A redacted secret never appears in any wire view layer: the Host strips
|
|
384
|
+
// role('secret') fields and reports them through a sidecar the scope
|
|
385
|
+
// snapshot does not expose, so there is nothing to compare the draft
|
|
386
|
+
// against. Settling is the only signal the form has; the rest of the
|
|
387
|
+
// batch, when one exists, still carries the atomic verdict by read-back.
|
|
410
388
|
if (this.specOf(field).secret) return true
|
|
411
389
|
return this.userLayer()?.[field] === value
|
|
412
390
|
}
|
|
413
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Read-back judgment for a planned unset: the field must be gone from the
|
|
394
|
+
* user layer once the mutation has settled.
|
|
395
|
+
*/
|
|
396
|
+
private landedUnset(field: string): boolean {
|
|
397
|
+
return !this.stored(field)
|
|
398
|
+
}
|
|
399
|
+
|
|
414
400
|
private stage(field: string, edit: StagedEdit): void {
|
|
415
401
|
this.staged.set(field, edit)
|
|
416
402
|
this.failed = false
|
package/src/dsh-home.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { Context } from '@deepseek-ai/cordis'
|
|
15
|
-
import {
|
|
15
|
+
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
17
17
|
import z from 'schemastery'
|
|
18
18
|
import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSection } from './service.ts'
|
|
@@ -189,20 +189,22 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
|
|
|
189
189
|
disposeRoutes = undefined
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
192
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
193
|
+
settingsCtx.settings.installSection(
|
|
194
|
+
ctx,
|
|
195
|
+
PET_SETTINGS_NAMESPACE as SettingsNamespace,
|
|
196
|
+
makePetSettingsSchema(service.selectedPetId()),
|
|
197
|
+
base,
|
|
198
|
+
{
|
|
199
|
+
setSource: (source) => { current = source },
|
|
200
|
+
onChange: () => {
|
|
201
|
+
const section = current()
|
|
202
|
+
service.applySettingsSection(section)
|
|
203
|
+
service.setEnabled(section.enabled ?? true)
|
|
204
|
+
syncRoutes()
|
|
205
|
+
},
|
|
204
206
|
},
|
|
205
|
-
|
|
206
|
-
)
|
|
207
|
+
)
|
|
208
|
+
})
|
|
207
209
|
syncRoutes()
|
|
208
210
|
}
|
package/src/ledger.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface LedgerConfig {
|
|
|
32
32
|
treats?: Partial<TreatConfig>
|
|
33
33
|
/** Per-pet remark pools for the selected pet (custom slots override built-ins). */
|
|
34
34
|
remarks?: PetRemarks
|
|
35
|
+
/** Voice-pack fallback remark pools (global or pet voice pack). */
|
|
36
|
+
voiceRemarks?: PetRemarks
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** Result of one ledger interaction (the shape the pet RPC returns). */
|
|
@@ -63,7 +65,7 @@ export class PetLedger {
|
|
|
63
65
|
constructor(persist: PetPersist, config: LedgerConfig = {}) {
|
|
64
66
|
this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
|
|
65
67
|
this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
|
|
66
|
-
this.picker = new RemarkPicker(config.remarks)
|
|
68
|
+
this.picker = new RemarkPicker(config.remarks, config.voiceRemarks)
|
|
67
69
|
this.current = persist
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -124,10 +126,10 @@ export class PetLedger {
|
|
|
124
126
|
|
|
125
127
|
/**
|
|
126
128
|
* Swap the reaction pools to another pet's custom remarks (called on pet
|
|
127
|
-
* selection). Slots the pet does not declare fall back to built-ins.
|
|
129
|
+
* selection). Slots the pet does not declare fall back to voice packs or built-ins.
|
|
128
130
|
*/
|
|
129
|
-
setRemarks(remarks?: PetRemarks): void {
|
|
130
|
-
this.picker = new RemarkPicker(remarks)
|
|
131
|
+
setRemarks(remarks?: PetRemarks, voiceRemarks?: PetRemarks): void {
|
|
132
|
+
this.picker = new RemarkPicker(remarks, voiceRemarks)
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/**
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Pairing trust fence shared by plugins that expose host routes: loopback
|
|
4
|
+
* (the desktop) always passes; a live paired-device cookie is an additional
|
|
5
|
+
* allow path when remote-web-ui is loaded. The consuming plugin never
|
|
6
|
+
* depends on that plugin — without the service the fence stays
|
|
7
|
+
* loopback-only.
|
|
8
|
+
*
|
|
9
|
+
* Per-package wrappers (access.ts) call this with their own name so each
|
|
10
|
+
* plugin keeps a self-describing export; the security decision lives only
|
|
11
|
+
* here.
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage } from 'node:http'
|
|
14
|
+
import { isLoopbackRequest } from './loopback.ts'
|
|
15
|
+
|
|
16
|
+
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
|
17
|
+
interface PairingAccess {
|
|
18
|
+
isPairedDevice(request: IncomingMessage): boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Structural host-context shape: shared sources carry no @deepseek-ai
|
|
23
|
+
* dependency (the shared package must typecheck standalone), so the fence
|
|
24
|
+
* reads only the two members it needs; cordis Context satisfies this.
|
|
25
|
+
* ctx.get is optional on the test harness; production Context always has it.
|
|
26
|
+
*/
|
|
27
|
+
interface LookupCtx {
|
|
28
|
+
get?(name: string, strict?: boolean): unknown
|
|
29
|
+
remoteWebUiPairing?: PairingAccess
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether this request may enter the plugin's host routes.
|
|
34
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
35
|
+
* @param request - the incoming HTTP request.
|
|
36
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
37
|
+
*/
|
|
38
|
+
export function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean {
|
|
39
|
+
if (isLoopbackRequest(request)) return true
|
|
40
|
+
const fromGet = typeof ctx.get === 'function' ? ctx.get('remoteWebUiPairing', false) : undefined
|
|
41
|
+
const pairing = (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)
|
|
42
|
+
return pairing?.isPairedDevice(request) === true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPairingAccess(value: unknown): value is PairingAccess {
|
|
46
|
+
return value !== undefined
|
|
47
|
+
&& value !== null
|
|
48
|
+
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
|
49
|
+
}
|
package/src/remarks.test.ts
CHANGED
|
@@ -54,6 +54,16 @@ describe('RemarkPicker', () => {
|
|
|
54
54
|
expect(picker.pick('pet')).toBe('专属摸头台词')
|
|
55
55
|
expect(picker.pick('feed')).toBe(BUILTIN_REMARKS.feed[0])
|
|
56
56
|
})
|
|
57
|
+
|
|
58
|
+
it('layers pet custom lines over voice pack lines, falling back to built-ins (#1226)', () => {
|
|
59
|
+
const picker = new RemarkPicker(
|
|
60
|
+
{ pet: ['Pet-level line'] },
|
|
61
|
+
{ pet: ['Voice-level pet line'], feed: ['Voice-level feed line'] },
|
|
62
|
+
)
|
|
63
|
+
expect(picker.pick('pet')).toBe('Pet-level line')
|
|
64
|
+
expect(picker.pick('feed')).toBe('Voice-level feed line')
|
|
65
|
+
expect(picker.pick('noTreats')).toBe(BUILTIN_REMARKS.noTreats[0])
|
|
66
|
+
})
|
|
57
67
|
})
|
|
58
68
|
|
|
59
69
|
describe('normalizePetRemarks', () => {
|
package/src/remarks.ts
CHANGED
|
@@ -184,11 +184,18 @@ export class RemarkPicker {
|
|
|
184
184
|
private readonly counters = new Map<RemarkKind, number>()
|
|
185
185
|
private readonly pools: Record<RemarkKind, readonly string[]>
|
|
186
186
|
|
|
187
|
-
constructor(overrides?: PetRemarks) {
|
|
187
|
+
constructor(overrides?: PetRemarks, voiceOverrides?: PetRemarks) {
|
|
188
188
|
this.pools = {} as Record<RemarkKind, readonly string[]>
|
|
189
189
|
for (const kind of REMARK_KINDS) {
|
|
190
190
|
const custom = overrides?.[kind]
|
|
191
|
-
|
|
191
|
+
const voice = voiceOverrides?.[kind]
|
|
192
|
+
if (custom !== undefined && custom.length > 0) {
|
|
193
|
+
this.pools[kind] = custom
|
|
194
|
+
} else if (voice !== undefined && voice.length > 0) {
|
|
195
|
+
this.pools[kind] = voice
|
|
196
|
+
} else {
|
|
197
|
+
this.pools[kind] = BUILTIN_REMARKS[kind]
|
|
198
|
+
}
|
|
192
199
|
}
|
|
193
200
|
}
|
|
194
201
|
|
package/src/service.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { Context, Service } from '@deepseek-ai/cordis'
|
|
16
16
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
17
17
|
import type { AffinityConfig, PetAffinityView, PetInteraction } from './affinity.ts'
|
|
18
|
+
import { announcementFresh, parseAnnouncement, type PetAnnouncement } from './announce.ts'
|
|
18
19
|
import type { TreatConfig } from './treats.ts'
|
|
19
20
|
import {
|
|
20
21
|
emptyProjectionRuntime,
|
|
@@ -191,6 +192,12 @@ export interface PetStateView {
|
|
|
191
192
|
* settles the view in memory but never writes pet.json.
|
|
192
193
|
*/
|
|
193
194
|
gameplay?: PetGameplayStateView
|
|
195
|
+
/**
|
|
196
|
+
* The freshest plugin-authored announcement (dsh-usage linkage), within
|
|
197
|
+
* its TTL; absent when none is fresh. Rendered as a dedicated styled
|
|
198
|
+
* bubble above the session stack.
|
|
199
|
+
*/
|
|
200
|
+
announcement?: PetAnnouncement
|
|
194
201
|
}
|
|
195
202
|
|
|
196
203
|
/** Result of `pet.interact`. */
|
|
@@ -224,7 +231,6 @@ declare module '@deepseek-ai/cordis' {
|
|
|
224
231
|
pet: PetService
|
|
225
232
|
}
|
|
226
233
|
}
|
|
227
|
-
|
|
228
234
|
/** Per-session pet activity: projection runtime plus the session's own machine. */
|
|
229
235
|
interface SessionActivity {
|
|
230
236
|
runtime: ProjectionRuntime
|
|
@@ -257,6 +263,8 @@ export class PetService extends Service {
|
|
|
257
263
|
private enabled: boolean
|
|
258
264
|
/** Status-decoration master switch (M5, #567); mirrored from settings. */
|
|
259
265
|
private decorationEnabled: boolean
|
|
266
|
+
/** The freshest plugin-authored announcement (dsh-usage linkage). */
|
|
267
|
+
private announcement: PetAnnouncement | undefined
|
|
260
268
|
private disposeActivity: (() => void) | undefined
|
|
261
269
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
262
270
|
private displaySession: Session | undefined
|
|
@@ -297,10 +305,12 @@ export class PetService extends Service {
|
|
|
297
305
|
persist = { ...persist, petId: this.registry.defaultEntry().id }
|
|
298
306
|
}
|
|
299
307
|
const selected = this.registry.byId(persist.petId) ?? this.registry.defaultEntry()
|
|
308
|
+
const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, selected.voice)?.remarks
|
|
300
309
|
const ledgerConfig: LedgerConfig = {
|
|
301
310
|
affinity: config.affinity,
|
|
302
311
|
treats: config.treats,
|
|
303
312
|
remarks: selected.remarks,
|
|
313
|
+
voiceRemarks,
|
|
304
314
|
}
|
|
305
315
|
this.ledger = new PetLedger(persist, ledgerConfig)
|
|
306
316
|
this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
|
|
@@ -338,6 +348,19 @@ export class PetService extends Service {
|
|
|
338
348
|
return this.view(currentSessionId)
|
|
339
349
|
}
|
|
340
350
|
|
|
351
|
+
/**
|
|
352
|
+
* RPC: one plugin-authored announcement bubble (dsh-usage linkage). The
|
|
353
|
+
* payload is validated into a bounded PetAnnouncement; a malformed one is
|
|
354
|
+
* dropped silently — a sibling plugin's bug must never surface as pet
|
|
355
|
+
* breakage. The announcement is in-memory only.
|
|
356
|
+
*/
|
|
357
|
+
announce(input: unknown): { ok: boolean } {
|
|
358
|
+
const parsed = parseAnnouncement(input, Date.now())
|
|
359
|
+
if (parsed === undefined) return { ok: false }
|
|
360
|
+
this.announcement = parsed
|
|
361
|
+
return { ok: true }
|
|
362
|
+
}
|
|
363
|
+
|
|
341
364
|
/** Current persisted display config (read-only view). */
|
|
342
365
|
display(): PetDisplayConfig {
|
|
343
366
|
return { ...this.ledger.snapshot.display }
|
|
@@ -390,7 +413,8 @@ export class PetService extends Service {
|
|
|
390
413
|
const entry = this.registry.byId(petId)
|
|
391
414
|
if (entry === undefined) return { ok: false, error: 'unknown-pet' }
|
|
392
415
|
this.ledger.setPetId(entry.id)
|
|
393
|
-
this.
|
|
416
|
+
const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.remarks
|
|
417
|
+
this.ledger.setRemarks(entry.remarks, voiceRemarks)
|
|
394
418
|
this.flush()
|
|
395
419
|
this.syncSettingsFromPet()
|
|
396
420
|
return { ok: true, petId: entry.id }
|
|
@@ -810,6 +834,11 @@ export class PetService extends Service {
|
|
|
810
834
|
}
|
|
811
835
|
// Read-only: the ledger settles on economic events only, never on a read,
|
|
812
836
|
// so polling the state cannot trigger pet.json writes.
|
|
837
|
+
// An expired announcement simply stops appearing (the client's 2 s poll
|
|
838
|
+
// drops it); no timer owns its removal.
|
|
839
|
+
const announcement = this.announcement !== undefined && announcementFresh(this.announcement, Date.now())
|
|
840
|
+
? this.announcement
|
|
841
|
+
: undefined
|
|
813
842
|
return {
|
|
814
843
|
animation: snapshot.animation,
|
|
815
844
|
...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
|
|
@@ -817,6 +846,7 @@ export class PetService extends Service {
|
|
|
817
846
|
sessionActive: snapshot.sessionActive,
|
|
818
847
|
sessions,
|
|
819
848
|
...(decoration === undefined ? {} : { decoration }),
|
|
849
|
+
...(announcement === undefined ? {} : { announcement }),
|
|
820
850
|
affinity: this.ledger.affinityView(Date.now()),
|
|
821
851
|
display: { ...this.ledger.snapshot.display },
|
|
822
852
|
pet: {
|
package/src/voice-pack.test.ts
CHANGED
|
@@ -53,6 +53,45 @@ describe('normalizeVoicePack structure', () => {
|
|
|
53
53
|
expect(warnings.join('\n')).toContain('mystery')
|
|
54
54
|
expect(warnings.join('\n')).toContain('voicePackVersion')
|
|
55
55
|
})
|
|
56
|
+
|
|
57
|
+
it('normalizes top-level remarks and ranks (#1226)', () => {
|
|
58
|
+
const { pack, warnings } = collectWarnings({
|
|
59
|
+
remarks: {
|
|
60
|
+
pet: ['Purr~ So comfortable!'],
|
|
61
|
+
feed: 'Yummy fish!',
|
|
62
|
+
},
|
|
63
|
+
ranks: {
|
|
64
|
+
'0': 'Baby Whale',
|
|
65
|
+
'幼鲸': 'Little Whale',
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
expect(pack).toBeDefined()
|
|
69
|
+
expect(pack?.remarks?.pet).toEqual(['Purr~ So comfortable!'])
|
|
70
|
+
expect(pack?.remarks?.feed).toEqual(['Yummy fish!'])
|
|
71
|
+
expect(pack?.ranks).toEqual({
|
|
72
|
+
'0': 'Baby Whale',
|
|
73
|
+
'幼鲸': 'Little Whale',
|
|
74
|
+
})
|
|
75
|
+
expect(warnings).toHaveLength(0)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('merges remarks and ranks across layers (#1226)', () => {
|
|
79
|
+
const base = normalizeVoicePack({
|
|
80
|
+
remarks: { pet: ['Base pet'], feed: ['Base feed'] },
|
|
81
|
+
ranks: { '0': 'Base 0', '25': 'Base 25' },
|
|
82
|
+
})
|
|
83
|
+
const layer = normalizeVoicePack({
|
|
84
|
+
remarks: { pet: ['Layer pet'] },
|
|
85
|
+
ranks: { '0': 'Layer 0' },
|
|
86
|
+
})
|
|
87
|
+
const merged = mergeVoicePacks(base, layer)
|
|
88
|
+
expect(merged?.remarks?.pet).toEqual(['Layer pet'])
|
|
89
|
+
expect(merged?.remarks?.feed).toEqual(['Base feed'])
|
|
90
|
+
expect(merged?.ranks).toEqual({
|
|
91
|
+
'0': 'Layer 0',
|
|
92
|
+
'25': 'Base 25',
|
|
93
|
+
})
|
|
94
|
+
})
|
|
56
95
|
})
|
|
57
96
|
|
|
58
97
|
describe('normalizePool', () => {
|