@linxin666/dsh-pet 0.3.19 → 0.3.20

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.
@@ -213,33 +213,73 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
213
213
  // the last painted frame instead of breaking playback).
214
214
  const decoding = new Map<string, Promise<DecodedFrame | undefined>>()
215
215
  const decodedAll: Promise<void>[] = []
216
- const loadFrame = (url: string): Promise<DecodedFrame | undefined> => {
216
+ // All frame fetches funnel through a small pool. Full-library warm passes
217
+ // on large pets fire 1100+ requests at once, which trips the browser's
218
+ // in-flight request limit (net::ERR_INSUFFICIENT_RESOURCES) and fails
219
+ // whole batches of frames while starving the rest of the page. Playback
220
+ // demand jumps ahead of the warm backlog.
221
+ const FRAME_POOL_LIMIT = 8
222
+ const frameQueue: Array<{ url: string; release: () => void }> = []
223
+ let activeFrames = 0
224
+
225
+ const decodeFrame = async (url: string): Promise<DecodedFrame | undefined> => {
226
+ try {
227
+ const response = await fetch(url)
228
+ if (!response.ok) throw new Error('http ' + response.status)
229
+ const bitmap = await createImageBitmap(await response.blob())
230
+ return { source: bitmap, width: bitmap.width, height: bitmap.height }
231
+ } catch {
232
+ // Fail-open: classic Image decode keeps non-modern runtimes alive.
233
+ return await new Promise<DecodedFrame | undefined>((resolve) => {
234
+ try {
235
+ const pre = new Image()
236
+ pre.onload = (): void => {
237
+ resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined)
238
+ }
239
+ pre.onerror = (): void => resolve(undefined)
240
+ pre.src = url
241
+ } catch {
242
+ resolve(undefined)
243
+ }
244
+ })
245
+ }
246
+ }
247
+
248
+ const pumpFrames = (): void => {
249
+ while (activeFrames < FRAME_POOL_LIMIT && frameQueue.length > 0) {
250
+ const queued = frameQueue.shift()!
251
+ activeFrames += 1
252
+ queued.release()
253
+ }
254
+ }
255
+
256
+ const loadFrame = (url: string, jump = false): Promise<DecodedFrame | undefined> => {
257
+ // Jump first: warm-enqueued frames already carry their memo, so a
258
+ // playback demand must reorder the unstarted entry before the cache
259
+ // lookup short-circuits.
260
+ if (jump) {
261
+ const index = frameQueue.findIndex((queued) => queued.url === url)
262
+ if (index > 0) frameQueue.unshift(frameQueue.splice(index, 1)[0]!)
263
+ }
217
264
  const cached = decoding.get(url)
218
265
  if (cached !== undefined) return cached
219
- const job: Promise<DecodedFrame | undefined> = (async () => {
220
- try {
221
- const response = await fetch(url)
222
- if (!response.ok) throw new Error('http ' + response.status)
223
- const bitmap = await createImageBitmap(await response.blob())
224
- return { source: bitmap, width: bitmap.width, height: bitmap.height }
225
- } catch {
226
- // Fail-open: classic Image decode keeps non-modern runtimes alive.
227
- return await new Promise<DecodedFrame | undefined>((resolve) => {
228
- try {
229
- const pre = new Image()
230
- pre.onload = (): void => {
231
- resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined)
232
- }
233
- pre.onerror = (): void => resolve(undefined)
234
- pre.src = url
235
- } catch {
236
- resolve(undefined)
237
- }
238
- })
239
- }
240
- })()
266
+ let release!: () => void
267
+ const gate = new Promise<void>((resolve) => { release = resolve })
268
+ const job: Promise<DecodedFrame | undefined> = gate.then(() => (disposed ? undefined : decodeFrame(url)))
269
+ job.then(
270
+ (frame) => { if (frame === undefined) decoding.delete(url) },
271
+ () => decoding.delete(url),
272
+ )
273
+ void job.finally(() => {
274
+ activeFrames -= 1
275
+ pumpFrames()
276
+ })
241
277
  decoding.set(url, job)
242
278
  decodedAll.push(job.then(() => undefined, () => undefined))
279
+ const entry = { url, release }
280
+ if (jump) frameQueue.unshift(entry)
281
+ else frameQueue.push(entry)
282
+ pumpFrames()
243
283
  return job
244
284
  }
245
285
 
@@ -268,7 +308,7 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
268
308
  const paintCanvas = (url: string): void => {
269
309
  if (context2d === null || canvas === null) return
270
310
  const myToken = ++drawToken
271
- void loadFrame(url).then((frame) => {
311
+ void loadFrame(url, true).then((frame) => {
272
312
  if (disposed || frame === undefined || myToken !== drawToken) return
273
313
  if (lastDrawnUrl === url) return
274
314
  lastDrawnUrl = url
@@ -363,7 +403,15 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
363
403
  // Warm pass: decode every frame up front (tiny same-origin webp files)
364
404
  // so loops and phase switches never wait on a first decode - same intent
365
405
  // as the historical Image-cache warm loop, now feeding the decode cache.
366
- for (const warmTrack of Object.values(config.tracks)) {
406
+ // Phase-reachable tracks enqueue first so early switches never trail the
407
+ // full warm backlog; demand loads jump the queue regardless.
408
+ const warmTrackIds: string[] = [
409
+ ...new Set([...Object.values(config.phases), config.phases.idle]),
410
+ ]
411
+ for (const warmTrack of [...warmTrackIds, ...Object.keys(config.tracks)].map(
412
+ (id) => config.tracks[id],
413
+ )) {
414
+ if (warmTrack === undefined) continue
367
415
  for (const warmUrl of warmTrack.frames) void loadFrame(warmUrl)
368
416
  }
369
417
 
@@ -378,7 +426,9 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
378
426
  if (timer !== undefined) clearTimeout(timer)
379
427
  if (watchdog !== undefined) clearInterval(watchdog)
380
428
  // Release decoded bitmaps after pending decodes settle; close() is
381
- // browser-only, so guard it for exotic hosts.
429
+ // browser-only, so guard it for exotic hosts. Queued-but-unstarted
430
+ // frames release immediately as no-ops so the settle barrier drains.
431
+ for (const queued of frameQueue.splice(0)) queued.release()
382
432
  void Promise.allSettled(decodedAll).then(() => {
383
433
  for (const job of decoding.values()) {
384
434
  void job.then((frame) => {
@@ -11,9 +11,16 @@
11
11
  * tool/result and turn/end — so the pet's inner voice always roughly knows
12
12
  * what is going on and never mis-fires on output text. The wall clock is
13
13
  * injected by the caller, keeping every projection reproducible.
14
+ *
15
+ * Since the 0.1.5-alpha.2 cohort the stream itself is no longer durable
16
+ * vocabulary: per-chunk phase input arrives through the process-local
17
+ * `agent/assistant-stream` publication ({@link projectAssistantStreamFrame}),
18
+ * while the durable log settles one `assistant/message` (or `assistant/attempt`)
19
+ * per attempt.
14
20
  * @module @linxin666/dsh-pet/event-projection
15
21
  */
16
22
 
23
+ import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
17
24
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
18
25
  import type { PetStateInput } from './state.ts'
19
26
  import {
@@ -102,24 +109,6 @@ export function projectOfficialEvent(
102
109
  runtime.activeTools.clear()
103
110
  runtime.stepHadFailure = false
104
111
  return { input: { phase: 'waiting', line: runtime.voice.scene('waiting', nowMs) } }
105
- case 'assistant/chunk': {
106
- const { chunk } = event.data
107
- if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
108
- const whisper = runtime.whispers.feed('thinking', nowMs)
109
- return {
110
- input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
111
- ...(whisper === undefined ? {} : { whisper }),
112
- }
113
- }
114
- if (chunk.type === 'text-delta' && chunk.text.length > 0) {
115
- const whisper = runtime.whispers.feed('writing', nowMs)
116
- return {
117
- input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
118
- ...(whisper === undefined ? {} : { whisper }),
119
- }
120
- }
121
- return undefined
122
- }
123
112
  case 'assistant/message':
124
113
  return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
125
114
  case 'tool/call': {
@@ -207,3 +196,33 @@ export function projectOfficialEvent(
207
196
  return undefined
208
197
  }
209
198
  }
199
+
200
+ /**
201
+ * Project one live `agent/assistant-stream` publication into the pet's visual
202
+ * phases. Chunk frames are the alpha.2 replacement for the retired durable
203
+ * `assistant/chunk` event: a reasoning delta keeps the pet thinking, a text
204
+ * delta moves it to review; start, end, and non-delta chunks change nothing.
205
+ */
206
+ export function projectAssistantStreamFrame(
207
+ frame: AssistantStreamFrame,
208
+ runtime: ProjectionRuntime,
209
+ nowMs: number = Date.now(),
210
+ ): PetActivityTransition | undefined {
211
+ if (frame.type !== 'chunk') return undefined
212
+ const { chunk } = frame
213
+ if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
214
+ const whisper = runtime.whispers.feed('thinking', nowMs)
215
+ return {
216
+ input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
217
+ ...(whisper === undefined ? {} : { whisper }),
218
+ }
219
+ }
220
+ if (chunk.type === 'text-delta' && chunk.text.length > 0) {
221
+ const whisper = runtime.whispers.feed('writing', nowMs)
222
+ return {
223
+ input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
224
+ ...(whisper === undefined ? {} : { whisper }),
225
+ }
226
+ }
227
+ return undefined
228
+ }
package/src/service.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { Context, Service } from '@deepseek-ai/cordis'
16
+ import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
16
17
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
17
18
  import type { AffinityConfig, PetAffinityView, PetInteraction } from './affinity.ts'
18
19
  import { announcementFresh, parseAnnouncement, type PetAnnouncement } from './announce.ts'
@@ -20,6 +21,7 @@ import type { TreatConfig } from './treats.ts'
20
21
  import {
21
22
  emptyProjectionRuntime,
22
23
  isActivityPhase,
24
+ projectAssistantStreamFrame,
23
25
  projectOfficialEvent,
24
26
  type ActivityStatusEventLike,
25
27
  type ProjectionRuntime,
@@ -466,6 +468,15 @@ export class PetService extends Service {
466
468
  this.rewardTurn(String(session.id), transition.completedTurn)
467
469
  }
468
470
  }),
471
+ this.ctx.on('agent/assistant-stream', ({ agent, frame }: { agent: { session: Session }; frame: AssistantStreamFrame }) => {
472
+ const session = agent.session
473
+ const runtime = this.activityOf(session).runtime
474
+ const transition = projectAssistantStreamFrame(frame, runtime)
475
+ if (transition === undefined) return
476
+ runtime.officialEventsSeen = true
477
+ this.officialEventSessions.add(session)
478
+ this.applyActivity(session, transition.input, transition.whisper)
479
+ }),
469
480
  this.ctx.on('session/disposed', (session: Session) => {
470
481
  this.ledger.forgetSession(String(session.id))
471
482
  this.officialEventSessions.delete(session)