fix: 分页问题
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Client-namespace projection of the session-stats domain: a pure re-export
|
||||
* of the package's types outlet. Client code imports ONLY the client
|
||||
* namespace (repo discipline), so `./client` projects the same single-source
|
||||
* content `./types` serves to host consumers — zero duplication.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-stats/client
|
||||
*/
|
||||
|
||||
export type * from './types.ts'
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Function plugin registering the `sessionStats` projection unit: whole-log
|
||||
* turn/step counts and LLM/tool/first-token/decode wall times served through
|
||||
* the session-projection seam (registry snapshot, change feed, and every
|
||||
* projection carrier), so clients render full-session figures that paging and
|
||||
* compaction cannot change. The plugin owns only the fold; delivery is the
|
||||
* seam's.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-stats
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { sessionStatsProjectionDefinition } from './projection.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'session-stats'
|
||||
/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
|
||||
export const inject = ['sessionProjections']
|
||||
|
||||
/**
|
||||
* Register the `sessionStats` unit; the registration is an effect on this
|
||||
* plugin's fiber, so unloading removes the key.
|
||||
* @param ctx - registrant context carrying the projection registry.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.sessionProjections.register(sessionStatsProjectionDefinition)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-stats`.
|
||||
* @module @deepseek-ai/dsh-session-stats/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-stats'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-stats-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the package owns a single pure projection fold whose
|
||||
* wire payload is schema-validated by the projection registry at every
|
||||
* snapshot and change-feed emission, and the event relations the fold relies
|
||||
* on (`step/end` exactly once per entered step, monotonic host-assigned turn
|
||||
* numbers, chunk and tool events carrying their step coordinates and call
|
||||
* ids) are owned and runtime-checked by dsh-agent-loop and the session
|
||||
* surface, not here.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The `sessionStats` projection unit: a pure fold of step boundaries, stream
|
||||
* chunks, tool pairs, and assembled assistant messages into whole-log counts
|
||||
* and wall times.
|
||||
*
|
||||
* `step/end` — not `assistant/message` — is the counted step event because it
|
||||
* is the step lifecycle authority: the loop appends exactly one per entered
|
||||
* step, in a `finally`, so completed, failed, cancelled, and max-tokens steps
|
||||
* all land one. Counting assembled assistant messages instead would overcount
|
||||
* max-tokens usage-host messages (empty content, excluded from the surface)
|
||||
* and undercount cancelled steps (aborted before the message assembles).
|
||||
*
|
||||
* The wall-time folds mirror the client window fold field by field
|
||||
* (`deriveStats` in dsh-client-ui-conversation, that fold's whole-window
|
||||
* fallback role): model time is `step/start` → `assistant/message`, first
|
||||
* token is the first non-empty delta chunk and survives an in-step
|
||||
* `llm/retry`, decode spans first token → assembled message on steps that
|
||||
* also report output tokens, and tool time pairs `tool/call` → `tool/result`
|
||||
* by callId. A cancelled step assembles no message, so its partial stream
|
||||
* time stays uncounted in every time figure — matching the window, which
|
||||
* renders it as an untimed interrupted node.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-stats/projection
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
/** Accumulated whole-log figures (the view is exactly these totals). */
|
||||
interface SessionStatsTotals {
|
||||
/** Distinct turns with at least one closed step so far. */
|
||||
turns: number
|
||||
/** Closed steps so far. */
|
||||
steps: number
|
||||
/** Summed model wall time over message-assembling steps, ms. */
|
||||
llmMs: number
|
||||
/** Summed matched tool call→result wall time, ms. */
|
||||
toolMs: number
|
||||
/** Summed first-token latency over `ttftSteps`, ms. */
|
||||
ttftMs: number
|
||||
/** Steps carrying a recorded first token. */
|
||||
ttftSteps: number
|
||||
/** Summed decode wall time over usage-reporting steps, ms. */
|
||||
decodeMs: number
|
||||
/** Summed provider output tokens over the same steps. */
|
||||
decodeTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold state: the totals plus the in-flight boundaries they accrue from.
|
||||
* Turn numbers are host-assigned and monotonic per session, so a single
|
||||
* `lastTurn` slot decides "first closed step of a new turn"; the state is
|
||||
* plain JSON per the unit contract (persisted-cache precondition).
|
||||
*/
|
||||
interface SessionStatsState extends SessionStatsTotals {
|
||||
/** Turn of the last counted `step/end`; null before the first. */
|
||||
lastTurn: number | null
|
||||
/** The open step's boundary facts; null outside a step or after its message assembled. */
|
||||
openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null
|
||||
/** Dispatch times of tool calls whose result has not landed, by callId. */
|
||||
pendingCalls: Record<string, number>
|
||||
}
|
||||
|
||||
const sessionStatsSchema = z.object({
|
||||
turns: z.number().int().nonnegative(),
|
||||
steps: z.number().int().nonnegative(),
|
||||
llmMs: z.number().nonnegative(),
|
||||
toolMs: z.number().nonnegative(),
|
||||
ttftMs: z.number().nonnegative(),
|
||||
ttftSteps: z.number().int().nonnegative(),
|
||||
decodeMs: z.number().nonnegative(),
|
||||
decodeTokens: z.number().nonnegative(),
|
||||
}).strict()
|
||||
|
||||
/**
|
||||
* Provider-reported completion tokens, guarded the way the window fold guards
|
||||
* node usage.
|
||||
* @param usage - the assistant/message event's optional usage record.
|
||||
* @returns the output-token count, or null when unreported or invalid.
|
||||
*/
|
||||
function usageOutputTokens(usage: unknown): number | null {
|
||||
if (typeof usage !== 'object' || usage === null) return null
|
||||
const value = (usage as { outputTokens?: unknown }).outputTokens
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
/** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
|
||||
export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState> = {
|
||||
key: 'sessionStats',
|
||||
schema: sessionStatsSchema,
|
||||
init: () => ({
|
||||
turns: 0,
|
||||
steps: 0,
|
||||
llmMs: 0,
|
||||
toolMs: 0,
|
||||
ttftMs: 0,
|
||||
ttftSteps: 0,
|
||||
decodeMs: 0,
|
||||
decodeTokens: 0,
|
||||
lastTurn: null,
|
||||
openStep: null,
|
||||
pendingCalls: {},
|
||||
}),
|
||||
apply: (state, event) => {
|
||||
// Every uninteresting event returns the same reference (Object.is gates the change feed).
|
||||
switch (event.type) {
|
||||
case 'step/start':
|
||||
return {
|
||||
...state,
|
||||
openStep: { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null },
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const open = state.openStep
|
||||
if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
|
||||
if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk)) return state
|
||||
return { ...state, openStep: { ...open, firstTokenTime: event.time } }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const open = state.openStep
|
||||
if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
|
||||
// One assembled message per step: closing the boundary means a
|
||||
// defensive duplicate cannot accrue twice.
|
||||
const next: SessionStatsState = {
|
||||
...state,
|
||||
llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
|
||||
openStep: null,
|
||||
}
|
||||
if (open.firstTokenTime !== null) {
|
||||
next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime)
|
||||
next.ttftSteps += 1
|
||||
const outputTokens = usageOutputTokens(event.data.usage)
|
||||
if (outputTokens !== null) {
|
||||
next.decodeMs += Math.max(0, event.time - open.firstTokenTime)
|
||||
next.decodeTokens += outputTokens
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
case 'tool/call':
|
||||
return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } }
|
||||
case 'tool/result': {
|
||||
const callId = event.data.message.source.callId
|
||||
const dispatched = state.pendingCalls[callId]
|
||||
if (dispatched === undefined) return state
|
||||
const pendingCalls = Object.fromEntries(
|
||||
Object.entries(state.pendingCalls).filter(([id]) => id !== callId),
|
||||
)
|
||||
return { ...state, toolMs: state.toolMs + Math.max(0, event.time - dispatched), pendingCalls }
|
||||
}
|
||||
case 'step/end':
|
||||
return {
|
||||
...state,
|
||||
turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
|
||||
steps: state.steps + 1,
|
||||
lastTurn: event.data.turn,
|
||||
openStep: null,
|
||||
}
|
||||
case 'turn/end':
|
||||
// A call whose result never landed belongs to a cancelled or failed
|
||||
// turn; results always land within their turn, so drop the leftovers
|
||||
// instead of growing persisted state forever.
|
||||
return Object.keys(state.pendingCalls).length === 0 ? state : { ...state, pendingCalls: {} }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
},
|
||||
view: state => ({
|
||||
turns: state.turns,
|
||||
steps: state.steps,
|
||||
llmMs: state.llmMs,
|
||||
toolMs: state.toolMs,
|
||||
ttftMs: state.ttftMs,
|
||||
ttftSteps: state.ttftSteps,
|
||||
decodeMs: state.decodeMs,
|
||||
decodeTokens: state.decodeTokens,
|
||||
}),
|
||||
stateVersion: 2,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Pure types of the session-stats domain: the ONE home of the `sessionStats`
|
||||
* projection-key declaration, free of this package's host-side value imports
|
||||
* (cordis context, zod, the llm chunk predicate). Two namespace projections
|
||||
* serve it — `./types` for host consumers, `./client` for client aggregates —
|
||||
* with zero content duplication.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-stats/types
|
||||
*/
|
||||
|
||||
// Marks this file a module so the declaration below AUGMENTS the projection
|
||||
// table instead of declaring an ambient module.
|
||||
export {}
|
||||
|
||||
/**
|
||||
* Whole-log conversation figures, independent of how much history a client
|
||||
* has paged in. Counts and wall times all fold from the complete durable log;
|
||||
* every field is 0 until its first contributing event lands. Field names
|
||||
* mirror the client window fold so an assembly without this unit can fall
|
||||
* back to it wholesale.
|
||||
*/
|
||||
export interface SessionStatsProjection {
|
||||
/** Distinct turns carrying at least one closed step (`step/end`); rejected or empty turns are uncounted. */
|
||||
turns: number
|
||||
/** Closed steps (`step/end` events) — completed, failed, and cancelled steps alike. */
|
||||
steps: number
|
||||
/** Summed model wall time (`step/start` → `assistant/message`) over steps that assembled a message. */
|
||||
llmMs: number
|
||||
/** Summed tool wall time over `tool/call` → `tool/result` pairs matched by callId. */
|
||||
toolMs: number
|
||||
/** Summed first-token latency (`step/start` → first non-empty delta chunk) over `ttftSteps`. */
|
||||
ttftMs: number
|
||||
/** Steps carrying a recorded first token. */
|
||||
ttftSteps: number
|
||||
/** Summed decode wall time (first token → `assistant/message`) over steps that also report output tokens. */
|
||||
decodeMs: number
|
||||
/** Summed provider output tokens over the same decode-timed steps. */
|
||||
decodeTokens: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Whole-log turn/step counts and wall times; see {@link SessionStatsProjection}. */
|
||||
sessionStats: SessionStatsProjection
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user