@luziyang2026/dsh-question-nav 0.2.0 → 0.4.0
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.md +10 -7
- package/README.zh.md +7 -4
- package/lib/client.js +142 -225
- package/lib/client.js.map +1 -1
- package/lib/index.js +93 -3
- package/lib/types/client/QuestionNavStrip.d.ts +11 -5
- package/lib/types/client/index.d.ts +10 -4
- package/lib/types/client/locales.d.ts +0 -6
- package/lib/types/core/history-index.d.ts +99 -0
- package/lib/types/core/question-entry.d.ts +17 -0
- package/lib/types/core/turn-dots.d.ts +48 -0
- package/lib/types/index.d.ts +17 -6
- package/lib/types/projection.d.ts +38 -0
- package/package.json +8 -4
- package/src/client/QuestionNavStrip.tsx +83 -103
- package/src/client/index.ts +20 -47
- package/src/client/locales.ts +0 -6
- package/src/client/question-nav.module.css +15 -24
- package/src/core/question-entry.ts +17 -0
- package/src/core/turn-dots.ts +98 -0
- package/src/index.ts +23 -6
- package/src/projection.ts +98 -0
- package/src/core/load-all.ts +0 -97
package/src/index.ts
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Host half of the dsh-question-nav plugin — runs in the DSH host process.
|
|
3
|
+
* Registers the `questionIndex` session projection unit: the ordered list of
|
|
4
|
+
* user questions (each tagged with its turn), folded from the session event
|
|
5
|
+
* log by the projection registry, persisted by the projection cache, and
|
|
6
|
+
* delivered to the browser through the standard projection carriers (history
|
|
7
|
+
* tail-page baseline + session/projection push frames). The navigation UI
|
|
8
|
+
* itself lives in the browser half (src/client).
|
|
6
9
|
*/
|
|
7
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import { questionIndexProjectionDefinition } from './projection.ts'
|
|
8
12
|
|
|
9
|
-
/**
|
|
10
|
-
export
|
|
13
|
+
/** Cordis plugin name. */
|
|
14
|
+
export const name = 'dsh-question-nav'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register the `questionIndex` unit. The registry is an optional capability
|
|
18
|
+
* (absent in headless compositions), so registration rides `ctx.inject`:
|
|
19
|
+
* without it the host half simply contributes nothing and the browser strip
|
|
20
|
+
* falls back to live-window questions.
|
|
21
|
+
* @param ctx - plugin context.
|
|
22
|
+
*/
|
|
23
|
+
export function apply(ctx: Context): void {
|
|
24
|
+
ctx.inject(['sessionProjections'], (inner) => {
|
|
25
|
+
inner.sessionProjections.register(questionIndexProjectionDefinition)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `questionIndex` session projection unit: a pure fold of the session
|
|
3
|
+
* event log into the ordered list of user questions, each tagged with the
|
|
4
|
+
* turn that claimed it. Registered on `ctx.sessionProjections` by the host
|
|
5
|
+
* half (src/index.ts); persistence, replay, and client delivery are the
|
|
6
|
+
* projection seam's (session-projection-cache checkpoints the state, the
|
|
7
|
+
* api-proxy carriers seed + push the wire view).
|
|
8
|
+
*
|
|
9
|
+
* Fold rules mirror the chat messageDefinition classification: an
|
|
10
|
+
* append-origin `user/message` with a human (`user`) source is a question;
|
|
11
|
+
* replacement copies (compaction checkpoints) and injected context are not.
|
|
12
|
+
* Turns come from `turn/start` boundaries, so retry/goal-continuation turns
|
|
13
|
+
* without a question simply produce no entry — dots may skip turn numbers,
|
|
14
|
+
* staying exactly aligned with the Trajectory view's turn labels.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-question-nav/projection
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { z } from 'zod'
|
|
20
|
+
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
|
21
|
+
import type { QuestionEntry } from './core/question-entry.ts'
|
|
22
|
+
|
|
23
|
+
/** Fold state: the last opened turn plus every question recorded so far. */
|
|
24
|
+
export interface QuestionIndexState {
|
|
25
|
+
/** Turn of the last `turn/start` (0 before any; turns are 1-based). */
|
|
26
|
+
turn: number
|
|
27
|
+
/** Every recorded question, in event order. */
|
|
28
|
+
questions: QuestionEntry[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
32
|
+
interface SessionProjectionMap {
|
|
33
|
+
questionIndex: QuestionEntry[]
|
|
34
|
+
}
|
|
35
|
+
interface SessionProjectionStateMap {
|
|
36
|
+
questionIndex: QuestionIndexState
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const questionEntrySchema = z.object({
|
|
41
|
+
turn: z.number().int().nonnegative(),
|
|
42
|
+
id: z.string(),
|
|
43
|
+
seq: z.number().int().nonnegative(),
|
|
44
|
+
time: z.number().nonnegative(),
|
|
45
|
+
text: z.string(),
|
|
46
|
+
}).strict()
|
|
47
|
+
|
|
48
|
+
/** Validates persisted rows after their `ver` gate (the unit's input boundary). */
|
|
49
|
+
const questionIndexStateSchema = z.object({
|
|
50
|
+
turn: z.number().int().nonnegative(),
|
|
51
|
+
questions: z.array(questionEntrySchema),
|
|
52
|
+
}).strict()
|
|
53
|
+
|
|
54
|
+
/** Validates the wire payload before it leaves the host. */
|
|
55
|
+
const questionIndexViewSchema = z.array(questionEntrySchema)
|
|
56
|
+
|
|
57
|
+
/** First text block of a user message; empty string when absent. */
|
|
58
|
+
function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {
|
|
59
|
+
if (content === undefined || content.length === 0) return ''
|
|
60
|
+
const first = content[0]
|
|
61
|
+
return typeof first?.text === 'string' ? first.text : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The `questionIndex` unit registered on `ctx.sessionProjections`. */
|
|
65
|
+
export const questionIndexProjectionDefinition: Omit<ProjectionDefinition<'questionIndex', QuestionIndexState>, 'wire'> & {
|
|
66
|
+
wire: NonNullable<ProjectionDefinition<'questionIndex', QuestionIndexState>['wire']>
|
|
67
|
+
} = {
|
|
68
|
+
key: 'questionIndex',
|
|
69
|
+
stateVersion: 1,
|
|
70
|
+
stateSchema: questionIndexStateSchema,
|
|
71
|
+
init: () => ({ turn: 0, questions: [] }),
|
|
72
|
+
apply: (state, event) => {
|
|
73
|
+
// Every uninteresting event returns the same reference (Object.is gates
|
|
74
|
+
// the change feed and the persisted-cache dirty check).
|
|
75
|
+
switch (event.type) {
|
|
76
|
+
case 'turn/start':
|
|
77
|
+
return event.data.turn === state.turn ? state : { ...state, turn: event.data.turn }
|
|
78
|
+
case 'user/message': {
|
|
79
|
+
if (event.surfaceOp !== 'append') return state
|
|
80
|
+
if (event.data.source?.kind !== 'user') return state
|
|
81
|
+
const entry: QuestionEntry = {
|
|
82
|
+
turn: state.turn,
|
|
83
|
+
id: String(event.data.id),
|
|
84
|
+
seq: event.seq,
|
|
85
|
+
time: event.time,
|
|
86
|
+
text: messageText(event.data.content),
|
|
87
|
+
}
|
|
88
|
+
return { ...state, questions: [...state.questions, entry] }
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return state
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
wire: {
|
|
95
|
+
viewSchema: questionIndexViewSchema,
|
|
96
|
+
view: state => state.questions,
|
|
97
|
+
},
|
|
98
|
+
}
|
package/src/core/load-all.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Load-all orchestration for the question-nav strip.
|
|
3
|
-
*
|
|
4
|
-
* DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds
|
|
5
|
-
* the currently loaded window, and questions that still sit behind the "load
|
|
6
|
-
* older" button are invisible to the strip until the window is expanded
|
|
7
|
-
* backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole
|
|
8
|
-
* history is materialized), so every user question becomes a dot.
|
|
9
|
-
*
|
|
10
|
-
* Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view
|
|
11
|
-
* liveness, clocks) so it is unit-testable without a browser or session.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export interface LoadAllSnapshot {
|
|
15
|
-
openState: string
|
|
16
|
-
hasMore: boolean
|
|
17
|
-
loadingOlder: boolean
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface LoadAllPorts {
|
|
21
|
-
/** Read the current session snapshot; undefined when unavailable. */
|
|
22
|
-
snapshot: () => LoadAllSnapshot | undefined
|
|
23
|
-
/** Expand the window backwards by one page (may preserve scroll). */
|
|
24
|
-
loadOlder: () => Promise<void>
|
|
25
|
-
/** True while the chat view is active (a `[data-chat-flow]` is mounted). */
|
|
26
|
-
isViewActive: () => boolean
|
|
27
|
-
/** Monotonic ms clock. */
|
|
28
|
-
now: () => number
|
|
29
|
-
/** Async sleep. */
|
|
30
|
-
sleep: (ms: number) => Promise<void>
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface LoadAllOptions {
|
|
34
|
-
/** Max older pages to fetch before giving up (default 400). */
|
|
35
|
-
maxPages?: number
|
|
36
|
-
/** Total wall-clock budget for the whole expansion (default 60s). */
|
|
37
|
-
totalTimeoutMs?: number
|
|
38
|
-
/** Poll interval for open/loading transitions (default 60ms). */
|
|
39
|
-
pollMs?: number
|
|
40
|
-
/** Abort the expansion; checked every iteration. */
|
|
41
|
-
signal?: AbortSignal
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export type LoadAllCode =
|
|
45
|
-
| 'COMPLETE'
|
|
46
|
-
| 'VIEW_INACTIVE'
|
|
47
|
-
| 'NOT_OPEN'
|
|
48
|
-
| 'BUDGET'
|
|
49
|
-
| 'TIMEOUT'
|
|
50
|
-
| 'CANCELLED'
|
|
51
|
-
|
|
52
|
-
export interface LoadAllResult {
|
|
53
|
-
ok: boolean
|
|
54
|
-
code: LoadAllCode
|
|
55
|
-
/** Number of `loadOlder` pages actually fetched. */
|
|
56
|
-
pages: number
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const DEFAULTS = {
|
|
60
|
-
maxPages: 400,
|
|
61
|
-
totalTimeoutMs: 60_000,
|
|
62
|
-
pollMs: 60,
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Expand the session window backwards until the earliest history is loaded.
|
|
67
|
-
* Waits while the session is still opening; aborts on cancellation, budget or
|
|
68
|
-
* timeout. Safe to re-enter: once `hasMore` is false the loop returns
|
|
69
|
-
* immediately with `COMPLETE`.
|
|
70
|
-
*/
|
|
71
|
-
export async function loadAllOlder(ports: LoadAllPorts, options: LoadAllOptions = {}): Promise<LoadAllResult> {
|
|
72
|
-
const cfg = { ...DEFAULTS, ...options }
|
|
73
|
-
const deadline = ports.now() + cfg.totalTimeoutMs
|
|
74
|
-
let pages = 0
|
|
75
|
-
|
|
76
|
-
const cancelled = (): boolean => cfg.signal?.aborted === true
|
|
77
|
-
|
|
78
|
-
while (true) {
|
|
79
|
-
if (cancelled()) return { ok: false, code: 'CANCELLED', pages }
|
|
80
|
-
if (!ports.isViewActive()) return { ok: false, code: 'VIEW_INACTIVE', pages }
|
|
81
|
-
const snap = ports.snapshot()
|
|
82
|
-
if (snap === undefined) return { ok: false, code: 'VIEW_INACTIVE', pages }
|
|
83
|
-
if (snap.openState === 'error') return { ok: false, code: 'NOT_OPEN', pages }
|
|
84
|
-
// Nothing older left: the whole history is in the window.
|
|
85
|
-
if (snap.hasMore !== true) return { ok: true, code: 'COMPLETE', pages }
|
|
86
|
-
if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', pages }
|
|
87
|
-
if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', pages }
|
|
88
|
-
// Wait while the session is still opening or a page is already in flight
|
|
89
|
-
// (a user-initiated "load older" click shares this same gate).
|
|
90
|
-
if (snap.openState !== 'open' || snap.loadingOlder) {
|
|
91
|
-
await ports.sleep(cfg.pollMs)
|
|
92
|
-
continue
|
|
93
|
-
}
|
|
94
|
-
await ports.loadOlder()
|
|
95
|
-
pages += 1
|
|
96
|
-
}
|
|
97
|
-
}
|