dsh-ntr 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/lib/bridge.js +1930 -60
- package/lib/client/activity-pill.js +156 -0
- package/lib/client/branch-action.js +16 -0
- package/lib/client/index.js +44 -4
- package/lib/client/model-seat.js +8 -2
- package/lib/client/store.js +32 -0
- package/lib/client.js +228 -5
- package/lib/client.js.map +1 -1
- package/lib/impl.js +1281 -23
- package/lib/index.js +27 -0
- package/lib/tps.js +32 -0
- package/lib/types/bridge.d.ts +636 -12
- package/lib/types/client/activity-pill.d.ts +81 -0
- package/lib/types/client/branch-action.d.ts +8 -1
- package/lib/types/client/model-seat.d.ts +21 -0
- package/lib/types/client/store.d.ts +10 -0
- package/lib/types/impl.d.ts +61 -6
- package/lib/types/protocol.d.ts +22 -0
- package/lib/types/tps.d.ts +17 -0
- package/package.json +1 -1
- package/src/bridge.ts +2216 -60
- package/src/client/activity-pill.tsx +201 -0
- package/src/client/branch-action.tsx +19 -1
- package/src/client/index.tsx +36 -4
- package/src/client/model-seat.tsx +28 -3
- package/src/client/store.ts +37 -0
- package/src/impl.ts +1268 -27
- package/src/index.ts +25 -0
- package/src/protocol.ts +24 -0
- package/src/tps.ts +40 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer dock's `x轮x步 · xx tok/s` pill on an ACP conversation.
|
|
3
|
+
*
|
|
4
|
+
* WHY SHADOW INSTEAD OF A SECOND PILL: the host's `ActivityPill` already states
|
|
5
|
+
* the two things this conversation has — turn/step counts and a whole-session
|
|
6
|
+
* decode speed — in the one place a speed belongs. Only its speed is wrong for
|
|
7
|
+
* ACP: the host divides the tokens a step emitted by `firstTokenTime →
|
|
8
|
+
* completedTime`, and that window contains every tool wait. Rendering a second
|
|
9
|
+
* speed beside it would leave two numbers on screen and no way to tell which
|
|
10
|
+
* is the honest one. So this module takes the host pill's own cell (`list` slot
|
|
11
|
+
* `conversation.composer.dock`, id `activity`) at a lower priority, renders the
|
|
12
|
+
* HOST's component, and hands it a patched `sessionStats` whose decode fields
|
|
13
|
+
* are the plugin's burst aggregate. Style, counts, dialog, aria and locale stay
|
|
14
|
+
* the host's; only the number changes.
|
|
15
|
+
*
|
|
16
|
+
* NON-ACP CONVERSATIONS PASS THROUGH. The shadow is installed only while the
|
|
17
|
+
* gate confirms the staged conversation runs the ACP preset, but a list entry
|
|
18
|
+
* is projected into EVERY session's composer. The wrapper therefore renders the
|
|
19
|
+
* host component with the ORIGINAL projection whenever `acpMode` is not
|
|
20
|
+
* affirmatively true, making the shadow visually indistinguishable from the
|
|
21
|
+
* shipped entry there.
|
|
22
|
+
*
|
|
23
|
+
* FAIL-OPEN IS STRUCTURAL: if the host entry, its inject face, its locale or
|
|
24
|
+
* its `performanceUsage` source is missing, no claim is created and the native
|
|
25
|
+
* pill stays exactly as shipped. If the host component rejects the patched
|
|
26
|
+
* projection, the slot's error boundary retires this entry and the native
|
|
27
|
+
* `activity` entry wins again. No CSS hiding and no DOM patching are involved.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createElement, useMemo } from 'react'
|
|
31
|
+
import type { ComponentType } from 'react'
|
|
32
|
+
// Type-only: pulls the conversation SlotMap merge into this program.
|
|
33
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
34
|
+
import type { SessionBurst } from '../protocol.ts'
|
|
35
|
+
import { useUi } from './acp-ui.tsx'
|
|
36
|
+
import type { AcpStore, UiState } from './store.ts'
|
|
37
|
+
import { createSeatClaim } from './model-seat.tsx'
|
|
38
|
+
import type { ModelSeatClaim, SeatEntry, SeatSlots } from './model-seat.tsx'
|
|
39
|
+
|
|
40
|
+
export const DOCK_SLOT = 'conversation.composer.dock'
|
|
41
|
+
export const ACTIVITY_ENTRY_ID = 'activity'
|
|
42
|
+
|
|
43
|
+
type ActivityProjection = (key: string, selector?: (value: unknown) => unknown) => unknown
|
|
44
|
+
type ActivityPerformanceHook = (selector?: (value: unknown) => unknown) => unknown
|
|
45
|
+
type ActivityTranslate = (key: string, params?: Record<string, unknown>) => string
|
|
46
|
+
|
|
47
|
+
/** The standard kit + injected face the shadow renders the host component with. */
|
|
48
|
+
export interface ActivityPillHostProps {
|
|
49
|
+
sessionId: string
|
|
50
|
+
useChat: unknown
|
|
51
|
+
useProjection: ActivityProjection
|
|
52
|
+
usePerformanceUsage: ActivityPerformanceHook
|
|
53
|
+
t?: ActivityTranslate
|
|
54
|
+
acp: AcpStore
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface OccupantProps extends ActivityPillHostProps {
|
|
58
|
+
host: ComponentType<Record<string, unknown>>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The aggregate the dock should show: the sum of every ACP session backing this
|
|
63
|
+
* chat (the host pill's counts are whole-chat too), with a live `turn/metrics`
|
|
64
|
+
* entry preferred per session over its persisted row. `undefined` means "no
|
|
65
|
+
* measurable burst yet" — the wrapper then hides the speed instead of showing
|
|
66
|
+
* the host's tool-inclusive number.
|
|
67
|
+
*/
|
|
68
|
+
export function activityBurst(
|
|
69
|
+
ui: Pick<UiState, 'chatSessions' | 'chatSessionsFor' | 'burstStats'>,
|
|
70
|
+
dshSessionId: string,
|
|
71
|
+
): SessionBurst | undefined {
|
|
72
|
+
if (ui.chatSessionsFor !== dshSessionId) return undefined
|
|
73
|
+
let outputTokens = 0
|
|
74
|
+
let burstMs = 0
|
|
75
|
+
for (const row of ui.chatSessions) {
|
|
76
|
+
const measured = ui.burstStats[row.sessionId] ?? row.burst
|
|
77
|
+
if (measured === undefined) continue
|
|
78
|
+
outputTokens += measured.outputTokens
|
|
79
|
+
burstMs += measured.burstMs
|
|
80
|
+
}
|
|
81
|
+
return outputTokens > 0 && burstMs > 0 ? { outputTokens, burstMs } : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Replace ONLY the decode half of the host's session stats.
|
|
86
|
+
*
|
|
87
|
+
* `decodeTokens` and `decodeMs` are the two fields the host's `decodeSpeed`
|
|
88
|
+
* reads (`decodeTokens / (decodeMs / 1000)`), so writing the plugin's aggregate
|
|
89
|
+
* there makes the host's own formatter, dialog row and aria label display the
|
|
90
|
+
* plugin's burst rate. Every other field — turns, steps, LLM/tool/TTFT — stays
|
|
91
|
+
* host-native. With no measurable burst the two decode fields are zeroed: the
|
|
92
|
+
* host then hides the speed (`decodeMs > 0` guard) while keeping the counts.
|
|
93
|
+
*/
|
|
94
|
+
export function patchedActivityStats(
|
|
95
|
+
native: Record<string, unknown> | undefined,
|
|
96
|
+
burst: SessionBurst | undefined,
|
|
97
|
+
acp: boolean,
|
|
98
|
+
): Record<string, unknown> | undefined {
|
|
99
|
+
if (!acp || native === undefined) return native
|
|
100
|
+
if (burst !== undefined && burst.outputTokens > 0 && burst.burstMs > 0) {
|
|
101
|
+
return { ...native, decodeTokens: burst.outputTokens, decodeMs: burst.burstMs }
|
|
102
|
+
}
|
|
103
|
+
return { ...native, decodeTokens: 0, decodeMs: 0 }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ActivityPillOccupant({ host: Host, ...props }: OccupantProps) {
|
|
107
|
+
const ui = useUi(props.acp)
|
|
108
|
+
const dshSessionId = String(props.sessionId)
|
|
109
|
+
const acpMode = ui.acpMode[dshSessionId] === true
|
|
110
|
+
const native = props.useProjection('sessionStats') as Record<string, unknown> | undefined
|
|
111
|
+
const burst = acpMode ? activityBurst(ui, dshSessionId) : undefined
|
|
112
|
+
const stats = useMemo(
|
|
113
|
+
() => patchedActivityStats(native, burst, acpMode),
|
|
114
|
+
[native, burst?.outputTokens, burst?.burstMs, acpMode],
|
|
115
|
+
)
|
|
116
|
+
// The current host pill reads exactly one projection. Returning `undefined`
|
|
117
|
+
// for another key keeps the wrapper from claiming support it has not been
|
|
118
|
+
// taught; a future host that asks for more will abdicate to the native entry.
|
|
119
|
+
const projection = useMemo<ActivityProjection>(
|
|
120
|
+
() => (key, selector) => {
|
|
121
|
+
if (key !== 'sessionStats') return undefined
|
|
122
|
+
return selector === undefined ? stats : selector(stats)
|
|
123
|
+
},
|
|
124
|
+
[stats],
|
|
125
|
+
)
|
|
126
|
+
return createElement(Host, {
|
|
127
|
+
useChat: props.useChat,
|
|
128
|
+
useProjection: acpMode ? projection : props.useProjection,
|
|
129
|
+
usePerformanceUsage: props.usePerformanceUsage,
|
|
130
|
+
t: props.t,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function findHostActivity(slots: SeatSlots): SeatEntry | undefined {
|
|
135
|
+
try {
|
|
136
|
+
if (slots.spec?.(DOCK_SLOT)?.kind !== 'list') return undefined
|
|
137
|
+
const heads = slots.entriesOfSlot?.(DOCK_SLOT) ?? []
|
|
138
|
+
return heads.find(entry => entry.options?.id === ACTIVITY_ENTRY_ID)
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ActivityPillClaimOptions {
|
|
145
|
+
slots: SeatSlots
|
|
146
|
+
/** Session-scoped face factory (the gate's `chatFace`). */
|
|
147
|
+
inject(sessionId: unknown, actions?: unknown): unknown
|
|
148
|
+
onDiag?(line: string): void
|
|
149
|
+
maxClaims?: number
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Take the dock's `activity` cell for one ACP-mode spell.
|
|
154
|
+
*
|
|
155
|
+
* Returns `undefined` on any host shape this bundle cannot serve — an older
|
|
156
|
+
* host without the `activity` entry (0.1.5 ships `stats`), a slot kind change,
|
|
157
|
+
* a missing inject/locale, or no `performanceUsage` source. The native pill is
|
|
158
|
+
* then left untouched; that is the honest failure mode, not a hidden control.
|
|
159
|
+
*/
|
|
160
|
+
export function createActivityPillClaim(options: ActivityPillClaimOptions): ModelSeatClaim | undefined {
|
|
161
|
+
const host = findHostActivity(options.slots)
|
|
162
|
+
if (host === undefined
|
|
163
|
+
|| host.component === undefined
|
|
164
|
+
|| typeof host.inject !== 'function'
|
|
165
|
+
|| typeof host.locale !== 'string'
|
|
166
|
+
|| host.locale === '') {
|
|
167
|
+
options.onDiag?.('activity pill: host entry not available')
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
let performanceUsage: unknown
|
|
171
|
+
try {
|
|
172
|
+
const face = host.inject() as { hooks?: { performanceUsage?: unknown } } | undefined
|
|
173
|
+
performanceUsage = face?.hooks?.performanceUsage
|
|
174
|
+
} catch {
|
|
175
|
+
performanceUsage = undefined
|
|
176
|
+
}
|
|
177
|
+
if (performanceUsage === undefined) {
|
|
178
|
+
options.onDiag?.('activity pill: host performance hook missing')
|
|
179
|
+
return undefined
|
|
180
|
+
}
|
|
181
|
+
const Host = host.component as ComponentType<Record<string, unknown>>
|
|
182
|
+
return createSeatClaim({
|
|
183
|
+
slots: options.slots,
|
|
184
|
+
slotKey: DOCK_SLOT,
|
|
185
|
+
entryId: ACTIVITY_ENTRY_ID,
|
|
186
|
+
order: host.options?.order ?? 0,
|
|
187
|
+
locale: host.locale,
|
|
188
|
+
basePriority: -1,
|
|
189
|
+
occupant: () => (props: Record<string, unknown>) => createElement(ActivityPillOccupant, {
|
|
190
|
+
...props,
|
|
191
|
+
host: Host,
|
|
192
|
+
} as unknown as OccupantProps),
|
|
193
|
+
inject: (sessionId, actions) => ({
|
|
194
|
+
...(options.inject(sessionId, actions) as Record<string, unknown>),
|
|
195
|
+
hooks: { performanceUsage },
|
|
196
|
+
}),
|
|
197
|
+
label: 'activity pill',
|
|
198
|
+
onDiag: options.onDiag,
|
|
199
|
+
maxClaims: options.maxClaims ?? 1,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
@@ -26,10 +26,28 @@ import type { ComponentType } from 'react'
|
|
|
26
26
|
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
27
27
|
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
28
28
|
import { useUi } from './acp-ui.tsx'
|
|
29
|
-
import type { AcpChatInjected } from './acp-ui.tsx'
|
|
29
|
+
import type { AcpChatInjected, AcpHostForkFace } from './acp-ui.tsx'
|
|
30
30
|
import { forkStateOf } from './store.ts'
|
|
31
31
|
import type { ChatForkState } from './store.ts'
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Resolves the runtime's `sessions.fork` primitive, binding it to the service
|
|
35
|
+
* instance so that internal `this` accesses (such as `this.list.getSnapshot()`)
|
|
36
|
+
* resolve correctly when called through `face.fork(...)`.
|
|
37
|
+
* Returns `undefined` if the service or `fork` method is absent or not a function.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveHostFork(sessionsService: unknown): AcpHostForkFace | undefined {
|
|
40
|
+
try {
|
|
41
|
+
const sessions = sessionsService as {
|
|
42
|
+
fork?: (options: { sessionId: string; increaseTitle?: boolean }) => Promise<unknown>
|
|
43
|
+
} | null | undefined
|
|
44
|
+
const fork = sessions?.fork
|
|
45
|
+
return typeof fork === 'function' ? { fork: fork.bind(sessions) } : undefined
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
/**
|
|
34
52
|
* The host's own branch-button icon, picked per host generation. The icon
|
|
35
53
|
* exports MOVED between shipped hosts — desktop 0.2.1-rc.2 has
|
package/src/client/index.tsx
CHANGED
|
@@ -26,7 +26,8 @@ import { AcpConversationView } from './acp-view.tsx'
|
|
|
26
26
|
import { AcpSettingsDialog } from './settings-dialog.tsx'
|
|
27
27
|
import { PermissionDock } from './permission-dock.tsx'
|
|
28
28
|
import { ReleasedBanner } from './released-banner.tsx'
|
|
29
|
-
import { AcpBranchAction } from './branch-action.tsx'
|
|
29
|
+
import { AcpBranchAction, resolveHostFork } from './branch-action.tsx'
|
|
30
|
+
import { createActivityPillClaim, DOCK_SLOT } from './activity-pill.tsx'
|
|
30
31
|
import {
|
|
31
32
|
createModelSeatClaim,
|
|
32
33
|
createPermissionSeatClaim,
|
|
@@ -241,8 +242,7 @@ export function apply(ctx: Context): void {
|
|
|
241
242
|
*/
|
|
242
243
|
const hostFork = (): AcpHostForkFace | undefined => {
|
|
243
244
|
try {
|
|
244
|
-
|
|
245
|
-
return typeof fork === 'function' ? { fork } : undefined
|
|
245
|
+
return resolveHostFork(service<StructuralServices['sessions']>('sessions'))
|
|
246
246
|
} catch {
|
|
247
247
|
return undefined
|
|
248
248
|
}
|
|
@@ -482,6 +482,26 @@ export function apply(ctx: Context): void {
|
|
|
482
482
|
* available yet.
|
|
483
483
|
*/
|
|
484
484
|
let permissionClaim: ModelSeatClaim | undefined
|
|
485
|
+
/**
|
|
486
|
+
* Claim on the dock's `activity` cell (`conversation.composer.dock`, id
|
|
487
|
+
* `activity`), held for the same ACP-mode spell as the two seats. The
|
|
488
|
+
* occupant renders the HOST's ActivityPill with the plugin's burst speed
|
|
489
|
+
* patched into its `sessionStats`; it is a list cell, so the claim is
|
|
490
|
+
* identified by id rather than by the slot's heads[0]. If the host shape is
|
|
491
|
+
* not one this bundle knows, no claim is created and the native pill stays.
|
|
492
|
+
*/
|
|
493
|
+
let activityClaim: ModelSeatClaim | undefined
|
|
494
|
+
let activityDiag = 'idle'
|
|
495
|
+
const noteActivityDiag = (line: string): void => {
|
|
496
|
+
if (line === activityDiag) return
|
|
497
|
+
activityDiag = line
|
|
498
|
+
// Success and the ordinary older-host "no activity entry" are silent; a
|
|
499
|
+
// shape mismatch or a lost registration must leave a breadcrumb. The ACP
|
|
500
|
+
// view has no line for this surface, and a missing speed fix is otherwise
|
|
501
|
+
// invisible.
|
|
502
|
+
if (!/failed|missing|taken|gave up|still held/.test(line)) return
|
|
503
|
+
console.warn(`[dsh-ntr] ${line}`)
|
|
504
|
+
}
|
|
485
505
|
/**
|
|
486
506
|
* ACP-only auxiliary entries: the NTR view tab and the composer's context
|
|
487
507
|
* ring. Unlike the two seats these are `list` slots with no visibility
|
|
@@ -567,6 +587,7 @@ export function apply(ctx: Context): void {
|
|
|
567
587
|
releaseAuxEntries()
|
|
568
588
|
if (claim !== undefined) { claim.release(); claim = undefined }
|
|
569
589
|
if (permissionClaim !== undefined) { permissionClaim.release(); permissionClaim = undefined }
|
|
590
|
+
if (activityClaim !== undefined) { activityClaim.release(); activityClaim = undefined }
|
|
570
591
|
}
|
|
571
592
|
/**
|
|
572
593
|
* Re-ask timer for the staged session, armed while the row is silent and a
|
|
@@ -706,6 +727,16 @@ export function apply(ctx: Context): void {
|
|
|
706
727
|
onDiag: (line) => { permissionSeatDiag = `staged ${staged.sessionId} · ${line}` },
|
|
707
728
|
})
|
|
708
729
|
permissionClaim.sync()
|
|
730
|
+
// The dock's activity pill is a list CELL, not a whole slot: this claim
|
|
731
|
+
// shadows the shipped `activity` entry by id and renders it back with the
|
|
732
|
+
// plugin's burst speed. No host entry / inject / locale -> undefined and
|
|
733
|
+
// the native pill is untouched; the next evaluation retries.
|
|
734
|
+
activityClaim ??= createActivityPillClaim({
|
|
735
|
+
slots: ctx.slots as unknown as SeatSlots,
|
|
736
|
+
inject: (sessionId: unknown) => chatFace(sessionId),
|
|
737
|
+
onDiag: noteActivityDiag,
|
|
738
|
+
})
|
|
739
|
+
activityClaim?.sync()
|
|
709
740
|
// The tab and the occupancy ring have the same lifetime as the claims:
|
|
710
741
|
// they exist only while this staged conversation is an ACP one, and a
|
|
711
742
|
// stand-down disposes them (a standard conversation must not see an NTR
|
|
@@ -826,7 +857,7 @@ export function apply(ctx: Context): void {
|
|
|
826
857
|
// retakes the cell only when it is actually holding something that is not
|
|
827
858
|
// ours — so this is evidence-driven, not a poll.
|
|
828
859
|
const stopCell = ctx.on('slots/changed', (...args: unknown[]) => {
|
|
829
|
-
if (args[0] !== MODEL_SEAT_KEY && args[0] !== PERMISSION_SEAT_KEY) return
|
|
860
|
+
if (args[0] !== MODEL_SEAT_KEY && args[0] !== PERMISSION_SEAT_KEY && args[0] !== DOCK_SLOT) return
|
|
830
861
|
runGate()
|
|
831
862
|
})
|
|
832
863
|
// Late services: the runtime's session list normally exists before this
|
|
@@ -847,6 +878,7 @@ export function apply(ctx: Context): void {
|
|
|
847
878
|
releaseAuxEntries()
|
|
848
879
|
if (claim !== undefined) { claim.release(); claim = undefined }
|
|
849
880
|
if (permissionClaim !== undefined) { permissionClaim.release(); permissionClaim = undefined }
|
|
881
|
+
if (activityClaim !== undefined) { activityClaim.release(); activityClaim = undefined }
|
|
850
882
|
store.noteSeatDiag('gate disposed')
|
|
851
883
|
store.notePermissionSeatDiag('gate disposed')
|
|
852
884
|
}
|
|
@@ -128,7 +128,11 @@ export const MAX_SEAT_CLAIMS = 4
|
|
|
128
128
|
/** One live entry in the cell, narrowed to what the claim reads. */
|
|
129
129
|
export interface SeatEntry {
|
|
130
130
|
component?: unknown
|
|
131
|
-
options?: { priority?: number; registrant?: string }
|
|
131
|
+
options?: { priority?: number; registrant?: string; id?: string; order?: number }
|
|
132
|
+
/** Entry-owned inject face; needed when the claim reuses a shipped occupant. */
|
|
133
|
+
inject?: (...args: unknown[]) => unknown
|
|
134
|
+
/** Locale namespace the occupant's `t` is bound to; needed for the same reuse. */
|
|
135
|
+
locale?: string
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
/** The slot-registry face this module uses (structural: the registry is not a dependency of this bundle). */
|
|
@@ -148,6 +152,12 @@ export interface SeatSlots {
|
|
|
148
152
|
*/
|
|
149
153
|
export interface SeatRegisterOptions {
|
|
150
154
|
name: string
|
|
155
|
+
/** List-slot cell to shadow; omitted for single/keyed registers. */
|
|
156
|
+
id?: string
|
|
157
|
+
/** Display position within the list; only meaningful with `id`. */
|
|
158
|
+
order?: number
|
|
159
|
+
/** Locale namespace the occupant's `t` is bound to. */
|
|
160
|
+
locale?: string
|
|
151
161
|
priority?: number
|
|
152
162
|
/**
|
|
153
163
|
* The session-scoped face factory the renderer calls with the scope binding's
|
|
@@ -227,6 +237,15 @@ export interface PermissionSeatClaimOptions {
|
|
|
227
237
|
export interface SeatClaimOptions {
|
|
228
238
|
slots: SeatSlots
|
|
229
239
|
slotKey: string
|
|
240
|
+
/**
|
|
241
|
+
* List-slot cell id to hold. Omitted for single/keyed seats; a list slot
|
|
242
|
+
* needs it because its winner view is one entry PER id, not one per slot.
|
|
243
|
+
*/
|
|
244
|
+
entryId?: string
|
|
245
|
+
/** Display order for the installed list entry (same cell, so usually the shipped order). */
|
|
246
|
+
order?: number
|
|
247
|
+
/** Locale namespace the installed occupant resolves `t` against. */
|
|
248
|
+
locale?: string
|
|
230
249
|
basePriority: number
|
|
231
250
|
occupant(): unknown
|
|
232
251
|
inject?(sessionId: unknown, actions?: unknown): unknown
|
|
@@ -265,7 +284,7 @@ interface CellView {
|
|
|
265
284
|
* `entriesOfSlot` and performing bounded retakes if displaced by later registrations.
|
|
266
285
|
*/
|
|
267
286
|
export function createSeatClaim(options: SeatClaimOptions): ModelSeatClaim {
|
|
268
|
-
const { slots, slotKey, basePriority, occupant, inject } = options
|
|
287
|
+
const { slots, slotKey, basePriority, occupant, inject, entryId } = options
|
|
269
288
|
const label = options.label ?? slotKey
|
|
270
289
|
const onDiag = options.onDiag
|
|
271
290
|
const maxClaims = options.maxClaims ?? MAX_SEAT_CLAIMS
|
|
@@ -293,7 +312,10 @@ export function createSeatClaim(options: SeatClaimOptions): ModelSeatClaim {
|
|
|
293
312
|
if (typeof slots.entriesOfSlot !== 'function') return { declared: isDeclared, verified: false }
|
|
294
313
|
try {
|
|
295
314
|
const heads = slots.entriesOfSlot(slotKey) ?? []
|
|
296
|
-
|
|
315
|
+
// A list slot projects one winner per id, so the cell this claim owns is
|
|
316
|
+
// the head WITH that id — not heads[0], which may be a sibling cell.
|
|
317
|
+
const entry = entryId === undefined ? heads[0] : heads.find(head => head.options?.id === entryId)
|
|
318
|
+
return { declared: isDeclared, verified: true, ...entry === undefined ? {} : { entry } }
|
|
297
319
|
} catch {
|
|
298
320
|
return { declared: isDeclared, verified: false }
|
|
299
321
|
}
|
|
@@ -312,6 +334,9 @@ export function createSeatClaim(options: SeatClaimOptions): ModelSeatClaim {
|
|
|
312
334
|
try {
|
|
313
335
|
installed.push(slots.inject(slotKey, () => slots.register({
|
|
314
336
|
name: slotKey,
|
|
337
|
+
...(entryId === undefined ? {} : { id: entryId }),
|
|
338
|
+
...(options.order === undefined ? {} : { order: options.order }),
|
|
339
|
+
...(options.locale === undefined ? {} : { locale: options.locale }),
|
|
315
340
|
priority: rank,
|
|
316
341
|
...(inject ? { inject } : {}),
|
|
317
342
|
}, component)))
|
package/src/client/store.ts
CHANGED
|
@@ -268,6 +268,11 @@ export interface UiState {
|
|
|
268
268
|
chatSessions: ChatSessionView[]
|
|
269
269
|
/** The DSH chat session id {@link chatSessions} was loaded for. */
|
|
270
270
|
chatSessionsFor?: string
|
|
271
|
+
/**
|
|
272
|
+
* Live burst aggregate per ACP session id, from `turn/metrics`. Absent means
|
|
273
|
+
* no measured burst yet; a row's persisted `burst` is the reload seed.
|
|
274
|
+
*/
|
|
275
|
+
burstStats: Record<string, { outputTokens: number; burstMs: number; turnId: string; final: boolean }>
|
|
271
276
|
/**
|
|
272
277
|
* The agent tag's halo preference (settings-editable display choice). The
|
|
273
278
|
* tag reads it per render; the host answer in `resync` is the authority and
|
|
@@ -687,6 +692,7 @@ export class AcpStore {
|
|
|
687
692
|
probingModels: {},
|
|
688
693
|
probeErrors: {},
|
|
689
694
|
chatSessions: [],
|
|
695
|
+
burstStats: {},
|
|
690
696
|
tagHalo: { ...DEFAULT_TAG_HALO },
|
|
691
697
|
approvalDeadlines: undefined,
|
|
692
698
|
idleRelease: undefined,
|
|
@@ -2234,11 +2240,20 @@ export class AcpStore {
|
|
|
2234
2240
|
// entry would keep reading as a live session (the agent tag's halo
|
|
2235
2241
|
// breathes on `!released`) until some future turn re-reads the list.
|
|
2236
2242
|
const chatSessions = this.state.chatSessions.filter(s => s.sessionId !== event.sessionId)
|
|
2243
|
+
// The burst aggregate belongs to this ACP session only; another
|
|
2244
|
+
// session's entry must survive. Only patch when there was an entry.
|
|
2245
|
+
const hadBurst = this.state.burstStats[event.sessionId] !== undefined
|
|
2246
|
+
let burstStats = this.state.burstStats
|
|
2247
|
+
if (hadBurst) {
|
|
2248
|
+
burstStats = { ...burstStats }
|
|
2249
|
+
delete burstStats[event.sessionId]
|
|
2250
|
+
}
|
|
2237
2251
|
this.mutate({
|
|
2238
2252
|
sessions,
|
|
2239
2253
|
activeSessionId: active,
|
|
2240
2254
|
chatTranscripts: closedTranscripts,
|
|
2241
2255
|
...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
|
|
2256
|
+
...(hadBurst ? { burstStats } : {}),
|
|
2242
2257
|
permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
|
|
2243
2258
|
...(event.reason === 'agent-died'
|
|
2244
2259
|
? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
|
|
@@ -2425,6 +2440,28 @@ export class AcpStore {
|
|
|
2425
2440
|
})
|
|
2426
2441
|
return Promise.resolve()
|
|
2427
2442
|
}
|
|
2443
|
+
case 'turn/metrics': {
|
|
2444
|
+
// Plugin-side burst aggregate for one ACP session. A late LIVE frame
|
|
2445
|
+
// must not regress a turn this page already saw finalized (frames can
|
|
2446
|
+
// land out of order across a stream reconnect); any other frame writes
|
|
2447
|
+
// the entry. Blocks / sessions stay untouched — this is a metric.
|
|
2448
|
+
const prior = this.state.burstStats[event.sessionId]
|
|
2449
|
+
if (event.final === false && prior !== undefined && prior.final === true && prior.turnId === event.turnId) {
|
|
2450
|
+
return Promise.resolve()
|
|
2451
|
+
}
|
|
2452
|
+
this.mutate({
|
|
2453
|
+
burstStats: {
|
|
2454
|
+
...this.state.burstStats,
|
|
2455
|
+
[event.sessionId]: {
|
|
2456
|
+
outputTokens: event.outputTokens,
|
|
2457
|
+
burstMs: event.burstMs,
|
|
2458
|
+
turnId: event.turnId,
|
|
2459
|
+
final: event.final,
|
|
2460
|
+
},
|
|
2461
|
+
},
|
|
2462
|
+
})
|
|
2463
|
+
return Promise.resolve()
|
|
2464
|
+
}
|
|
2428
2465
|
case 'turn/start': {
|
|
2429
2466
|
this.openTurns.add(event.sessionId)
|
|
2430
2467
|
this.markWatchedRunning(event.sessionId, true)
|