projection.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. /**
  2. * The `turnOutline` projection unit: a pure fold of `turn/start` boundaries,
  3. * first human prompts, and final assistant responses into the whole-log turn
  4. * outline the chat rail renders for turns outside a client's paged event
  5. * window.
  6. *
  7. * `turn/start` — not the prompt `user/message` — anchors each entry because
  8. * its seq is the load-through target for a jump: the loop logs `turn/start`
  9. * before the turn's prompt and steps, so a window paged back through that seq
  10. * contains the whole turn. Previews mirror the rail's loaded-turn previews
  11. * (space-joined text blocks, collapsed whitespace, an ellipsis when clipped)
  12. * with budgets sized to the rail card's clamps — one prompt line, up to three
  13. * response lines — so a turn shows the same words before and after its events
  14. * load. The response commits at `turn/end` from a draft of the newest
  15. * text-bearing assistant message; draft-only applies keep the `turns` array's
  16. * identity, so the identity-gated change feed pushes at most three times per
  17. * turn (boundary, prompt, response).
  18. *
  19. * @module @deepseek-ai/dsh-session-turn-outline/projection
  20. */
  21. import { z } from 'zod'
  22. import type { ZodType } from 'zod'
  23. import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
  24. import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
  25. import type { TurnOutlineEntry, TurnOutlineState } from './types.ts'
  26. /** Prompt budget: one rail-card line (13px over ~276px), ASCII worst case included. */
  27. const PROMPT_PREVIEW_LIMIT = 50
  28. /** Response budget: three rail-card lines (12px over ~276px). */
  29. const RESPONSE_PREVIEW_LIMIT = 120
  30. type MessageContent = SessionEvent<'user/message'>['data']['content']
  31. /** Space-join text blocks, collapse whitespace, and cap at `limit` with a trailing ellipsis when clipped. */
  32. function preview(content: MessageContent, limit: number): string {
  33. let text = ''
  34. let unread = false
  35. for (const block of content) {
  36. if (block.type !== 'text') continue
  37. if (text.length >= limit * 2) {
  38. unread = true
  39. break
  40. }
  41. // Per-block bound: the fold runs on every message event, so a single
  42. // multi-megabyte block must not be concatenated (and regex-normalized)
  43. // whole for a preview this short.
  44. const clipped = block.text.length > limit * 2
  45. const chunk = clipped ? block.text.slice(0, limit * 2) : block.text
  46. text += text === '' ? chunk : ` ${chunk}`
  47. if (clipped) {
  48. unread = true
  49. break
  50. }
  51. }
  52. const normalized = text.replace(/\s+/g, ' ').trim()
  53. if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`
  54. return unread ? `${normalized}…` : normalized
  55. }
  56. const turnOutlineEntriesSchema: ZodType<readonly TurnOutlineEntry[]> = z.array(z.object({
  57. turn: z.number().int().nonnegative(),
  58. seq: z.number().int().nonnegative().transform(SessionSeq),
  59. prompt: z.string().max(PROMPT_PREVIEW_LIMIT),
  60. response: z.string().max(RESPONSE_PREVIEW_LIMIT),
  61. }).strict()).superRefine((turns, context) => {
  62. let previous = -1
  63. for (const entry of turns) {
  64. if (entry.turn <= previous) {
  65. context.addIssue({ code: 'custom', message: 'turn outline entries must be strictly increasing by turn' })
  66. return
  67. }
  68. previous = entry.turn
  69. }
  70. })
  71. const turnOutlineStateSchema: ZodType<TurnOutlineState> = z.object({
  72. turns: turnOutlineEntriesSchema,
  73. draft: z.string().max(RESPONSE_PREVIEW_LIMIT),
  74. }).strict()
  75. const EMPTY_OUTLINE: TurnOutlineState = { turns: [], draft: '' }
  76. /** The `turnOutline` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
  77. export const turnOutlineProjectionDefinition = {
  78. key: 'turnOutline',
  79. stateVersion: 2,
  80. stateSchema: turnOutlineStateSchema,
  81. init: () => EMPTY_OUTLINE,
  82. apply: (state, event) => {
  83. // Every uninteresting event returns the same reference (Object.is gates
  84. // the drive), and draft-only changes keep `turns` identity (the raw-view
  85. // identity gate then keeps the change feed quiet).
  86. switch (event.type) {
  87. case 'turn/start': {
  88. const last = state.turns.at(-1)
  89. // Order guard: a boundary that does not advance the turn number keeps
  90. // the outline sorted, and a retried turn's previews land on the
  91. // standing entry.
  92. if (last !== undefined && event.data.turn <= last.turn) return state
  93. return {
  94. turns: [...state.turns, { turn: event.data.turn, seq: event.seq, prompt: '', response: '' }],
  95. draft: '',
  96. }
  97. }
  98. case 'user/message': {
  99. // Only the newest turn can still be waiting for its opening human
  100. // prompt; later human messages in the same turn (steering) keep the
  101. // first preview.
  102. if (event.data.source.kind !== 'user') return state
  103. const last = state.turns.at(-1)
  104. if (last === undefined || last.prompt !== '') return state
  105. const prompt = preview(event.data.content, PROMPT_PREVIEW_LIMIT)
  106. if (prompt === '') return state
  107. return { turns: [...state.turns.slice(0, -1), { ...last, prompt }], draft: state.draft }
  108. }
  109. case 'assistant/message': {
  110. // Newest text-bearing message wins; the buffer commits at turn/end.
  111. const draft = preview(event.data.message.content, RESPONSE_PREVIEW_LIMIT)
  112. if (draft === '' || draft === state.draft) return state
  113. return { turns: state.turns, draft }
  114. }
  115. case 'turn/end': {
  116. if (state.draft === '') return state
  117. const last = state.turns.at(-1)
  118. if (last === undefined || last.response === state.draft) return { turns: state.turns, draft: '' }
  119. return { turns: [...state.turns.slice(0, -1), { ...last, response: state.draft }], draft: '' }
  120. }
  121. default:
  122. return state
  123. }
  124. },
  125. wire: {
  126. viewSchema: turnOutlineEntriesSchema,
  127. view: state => state.turns,
  128. },
  129. } satisfies ProjectionDefinition<'turnOutline', TurnOutlineState>