@luziyang2026/dsh-question-nav 0.1.0 → 0.3.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 +28 -18
- package/README.zh.md +24 -15
- package/lib/client.js +344 -72
- package/lib/client.js.map +1 -1
- package/lib/types/client/QuestionNavStrip.d.ts +5 -2
- package/lib/types/client/index.d.ts +10 -3
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/core/history-index.d.ts +99 -0
- package/lib/types/core/load-all.d.ts +53 -0
- package/lib/types/core/nodes.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/QuestionNavStrip.tsx +105 -9
- package/src/client/index.ts +53 -4
- package/src/client/locales.ts +6 -0
- package/src/client/question-nav.module.css +24 -0
- package/src/core/history-index.ts +176 -0
- package/src/core/nodes.ts +14 -0
package/src/client/index.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-half entry for the dsh-
|
|
2
|
+
* Browser-half entry for the dsh-question-nav plugin.
|
|
3
3
|
*
|
|
4
4
|
* Registers one surface into the frame-wide floating layer (`shell.overlay`):
|
|
5
|
-
* a vertical strip on the
|
|
5
|
+
* a vertical strip on the LEFT edge of the conversation column listing every
|
|
6
6
|
* user question in the current session as a small button. Clicking a button
|
|
7
|
-
* scrolls the chat to that question
|
|
7
|
+
* scrolls the chat to that question.
|
|
8
|
+
*
|
|
9
|
+
* The strip indexes the WHOLE session history WITHOUT expanding DSH's paged
|
|
10
|
+
* render window: it pages the raw `session.history` RPC (read-only, no render
|
|
11
|
+
* cost) and derives each question's chat anchor key from the event. Only when
|
|
12
|
+
* a dot is clicked does the jump loop call `loadOlder()` to bring that
|
|
13
|
+
* specific page into the window — so the conversation's memory economy is
|
|
14
|
+
* preserved.
|
|
8
15
|
*
|
|
9
16
|
* Failure policy: nothing here throws at apply time — an external plugin must
|
|
10
17
|
* never take the GUI down.
|
|
11
18
|
*/
|
|
12
19
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
13
20
|
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
|
21
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
14
22
|
// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').
|
|
15
23
|
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
16
24
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
@@ -19,6 +27,7 @@ import { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.t
|
|
|
19
27
|
import { en, zh, type QuestionNavKey } from './locales.ts'
|
|
20
28
|
import { extractQuestions } from '../core/nodes.ts'
|
|
21
29
|
import { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'
|
|
30
|
+
import { buildQuestionIndex, type HistoryIndexOptions, type HistoryIndexResult, type RawEventLike } from '../core/history-index.ts'
|
|
22
31
|
|
|
23
32
|
/** Locale namespace this plugin owns. */
|
|
24
33
|
const NS = 'question-nav'
|
|
@@ -31,7 +40,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/** Services required by this plugin. */
|
|
34
|
-
export const inject = ['slots', 'locale', 'sessions']
|
|
43
|
+
export const inject = ['slots', 'locale', 'sessions', 'connection']
|
|
35
44
|
|
|
36
45
|
/** Single-instance guard: a duplicated client injection must not mount twice. */
|
|
37
46
|
declare global {
|
|
@@ -81,6 +90,45 @@ function jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {
|
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/** Resolve the connection handle (shared API client) as other DSH plugins do. */
|
|
94
|
+
function connectionOf(ctx: ClientContext): ConnectionHandle {
|
|
95
|
+
return ctx.get('connection') as ConnectionHandle
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One raw history page, mapped to the pure `buildQuestionIndex` port shape.
|
|
100
|
+
* `beforeSeq` is exclusive; `undefined` reads the newest page. Returns
|
|
101
|
+
* undefined when the page is unavailable so the builder stops cleanly. The
|
|
102
|
+
* SDK's `SessionEvent` is cast to the structural `RawEventLike` at this
|
|
103
|
+
* boundary (the index reader only touches type/seq/time/surfaceOp/data).
|
|
104
|
+
*/
|
|
105
|
+
async function rawHistoryPage(
|
|
106
|
+
ctx: ClientContext,
|
|
107
|
+
sessionId: SessionId,
|
|
108
|
+
beforeSeq: number | undefined,
|
|
109
|
+
maxMessages: number,
|
|
110
|
+
): Promise<{ events: readonly { event: RawEventLike }[]; hasMore: boolean } | undefined> {
|
|
111
|
+
const { api } = connectionOf(ctx)
|
|
112
|
+
const { result } = await api.sessions.history({ sessionId, beforeSeq, maxMessages })
|
|
113
|
+
if (!result.ok) return undefined
|
|
114
|
+
return {
|
|
115
|
+
events: result.value.events.map((entry) => ({ event: entry.event as unknown as RawEventLike })),
|
|
116
|
+
hasMore: result.value.hasMore,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Build the full-session question index from the raw history RPC (no render). */
|
|
121
|
+
function buildIndexFor(
|
|
122
|
+
ctx: ClientContext,
|
|
123
|
+
sessionId: SessionId,
|
|
124
|
+
options: HistoryIndexOptions = {},
|
|
125
|
+
): Promise<HistoryIndexResult> {
|
|
126
|
+
return buildQuestionIndex({
|
|
127
|
+
history: (beforeSeq, maxMessages) => rawHistoryPage(ctx, sessionId, beforeSeq, maxMessages),
|
|
128
|
+
now: () => Date.now(),
|
|
129
|
+
}, options)
|
|
130
|
+
}
|
|
131
|
+
|
|
84
132
|
function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
85
133
|
return {
|
|
86
134
|
readQuestions: (sessionId) => {
|
|
@@ -103,6 +151,7 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
|
103
151
|
}
|
|
104
152
|
void jumpToQuestion(ports, key)
|
|
105
153
|
},
|
|
154
|
+
fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options),
|
|
106
155
|
}
|
|
107
156
|
}
|
|
108
157
|
|
package/src/client/locales.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const zh = {
|
|
6
6
|
'strip.empty': '本会话还没有提问',
|
|
7
|
+
'strip.loadingAll': '正在加载全部历史…',
|
|
8
|
+
'strip.loadingSuffix': '…',
|
|
9
|
+
'strip.loadEarlier': '加载更早的问题',
|
|
7
10
|
'jump.inactive': '聊天视图未激活',
|
|
8
11
|
'jump.hidden': '目标无独立气泡,已定位到邻近内容',
|
|
9
12
|
'jump.notfound': '目标未加载或不存在(可能已压缩)',
|
|
@@ -12,6 +15,9 @@ export const zh = {
|
|
|
12
15
|
|
|
13
16
|
export const en = {
|
|
14
17
|
'strip.empty': 'No questions in this session yet',
|
|
18
|
+
'strip.loadingAll': 'Loading full history…',
|
|
19
|
+
'strip.loadingSuffix': '…',
|
|
20
|
+
'strip.loadEarlier': 'Load earlier questions',
|
|
15
21
|
'jump.inactive': 'Chat view is not active',
|
|
16
22
|
'jump.hidden': 'No dedicated bubble; landed on nearby content',
|
|
17
23
|
'jump.notfound': 'Target not loaded or missing (maybe compacted)',
|
|
@@ -58,6 +58,17 @@
|
|
|
58
58
|
background: var(--dsw-alias-brand-primary);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/* Dimmed placeholder for unloaded older history (below the count, above the
|
|
62
|
+
oldest question): click to page in more history. */
|
|
63
|
+
.moreDot {
|
|
64
|
+
background: transparent;
|
|
65
|
+
border: 1px dashed var(--dsw-alias-border-l3);
|
|
66
|
+
}
|
|
67
|
+
.moreDot:hover {
|
|
68
|
+
background: var(--dsw-alias-brand-primary);
|
|
69
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
/* Question count, rendered just above the first dot (sits in the list gap). */
|
|
62
73
|
.count {
|
|
63
74
|
flex: none;
|
|
@@ -68,6 +79,19 @@
|
|
|
68
79
|
user-select: none;
|
|
69
80
|
}
|
|
70
81
|
|
|
82
|
+
/* Ellipsis shown while the full history is being expanded. */
|
|
83
|
+
.countLoading {
|
|
84
|
+
margin-left: 2px;
|
|
85
|
+
font-weight: 400;
|
|
86
|
+
color: var(--dsw-alias-brand-primary);
|
|
87
|
+
animation: qnPulse 1.2s ease-in-out infinite;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@keyframes qnPulse {
|
|
91
|
+
0%, 100% { opacity: 0.4; }
|
|
92
|
+
50% { opacity: 1; }
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
/* The centered group: count + dot column, centered together (the list's auto
|
|
72
96
|
margins center it when short; it scrolls as a unit when tall). */
|
|
73
97
|
.dots {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Question-index builder over the raw session history RPC.
|
|
3
|
+
*
|
|
4
|
+
* DSH pages the rendered conversation window on purpose (memory economy):
|
|
5
|
+
* `chat.nodes` only ever holds the loaded window, and force-expanding it
|
|
6
|
+
* (repeated `loadOlder()`) materializes + renders the whole log — the exact
|
|
7
|
+
* cost DSH's paging exists to avoid. This module instead builds a lightweight
|
|
8
|
+
* index of every user question by paging the RAW history RPC (`session.history`
|
|
9
|
+
* with `beforeSeq`), which reads the host log without touching the render
|
|
10
|
+
* window at all. Only `{key, seq, time, text}` per question is retained.
|
|
11
|
+
*
|
|
12
|
+
* The chat anchor key is derived deterministically from the event — it equals
|
|
13
|
+
* `conversationContextKey('input-message', String(event.data.id))` — so the
|
|
14
|
+
* dots can target rows that are not loaded yet, and a click then pages the
|
|
15
|
+
* window on demand (see `jump.ts`).
|
|
16
|
+
*
|
|
17
|
+
* Pure-ish: takes injected ports (one raw history page read, clocks) so it is
|
|
18
|
+
* unit-testable without a browser or a live session.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { QuestionNode } from './nodes.ts'
|
|
22
|
+
import { messageText } from './nodes.ts'
|
|
23
|
+
|
|
24
|
+
/** Minimal shape of a raw history event (structural, not SDK-bound). */
|
|
25
|
+
export interface RawEventLike {
|
|
26
|
+
type: string
|
|
27
|
+
seq: number
|
|
28
|
+
time: number
|
|
29
|
+
surfaceOp?: unknown
|
|
30
|
+
data?: {
|
|
31
|
+
id?: unknown
|
|
32
|
+
source?: { kind?: string; plugin?: string }
|
|
33
|
+
content?: readonly { type?: string; text?: string }[]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The conversation Definition kind whose key a user question node uses. */
|
|
38
|
+
export const MESSAGE_DEFINITION_KIND = 'input-message'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The engine-owned stable chat key for a user question event — mirrors
|
|
42
|
+
* `conversationContextKey('input-message', String(id))` from the DSH runtime
|
|
43
|
+
* (verified against it in the unit test).
|
|
44
|
+
*/
|
|
45
|
+
export function questionKey(id: unknown): string {
|
|
46
|
+
const kind = MESSAGE_DEFINITION_KIND
|
|
47
|
+
return `${kind.length}:${kind}${String(id)}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a raw event is one user question the strip should index.
|
|
52
|
+
* Mirrors the DSH `messageDefinition` match + `start` classification:
|
|
53
|
+
* an append-origin `user/message` with a human (`user`) source. Replacement
|
|
54
|
+
* copies (compaction checkpoints, `source.kind === 'plugin'`) and injected
|
|
55
|
+
* context (`source.kind !== 'user'`) are excluded.
|
|
56
|
+
*/
|
|
57
|
+
export function isQuestionEvent(event: RawEventLike): boolean {
|
|
58
|
+
if (event.type !== 'user/message') return false
|
|
59
|
+
if (event.surfaceOp !== 'append') return false
|
|
60
|
+
return event.data?.source?.kind === 'user'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Map one raw question event to a strip question node, or null when not one. */
|
|
64
|
+
export function questionFromEvent(event: RawEventLike): QuestionNode | null {
|
|
65
|
+
if (!isQuestionEvent(event)) return null
|
|
66
|
+
return {
|
|
67
|
+
key: questionKey(event.data?.id),
|
|
68
|
+
anchorSeq: event.seq,
|
|
69
|
+
seq: event.seq,
|
|
70
|
+
time: event.time,
|
|
71
|
+
text: messageText(event.data?.content),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface HistoryIndexPorts {
|
|
76
|
+
/**
|
|
77
|
+
* Read one raw history page. `beforeSeq` is exclusive (events with seq <
|
|
78
|
+
* beforeSeq); `undefined` reads the newest page. Resolves undefined when
|
|
79
|
+
* the page is unavailable (session gone / transport error).
|
|
80
|
+
*/
|
|
81
|
+
history: (
|
|
82
|
+
beforeSeq: number | undefined,
|
|
83
|
+
maxMessages: number,
|
|
84
|
+
) => Promise<{ events: readonly { event: RawEventLike }[]; hasMore: boolean } | undefined>
|
|
85
|
+
/** Monotonic ms clock. */
|
|
86
|
+
now: () => number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface HistoryIndexOptions {
|
|
90
|
+
/** Raw messages per page (default 100). */
|
|
91
|
+
maxMessages?: number
|
|
92
|
+
/** Max pages before giving up (default 200 => 20k messages). */
|
|
93
|
+
maxPages?: number
|
|
94
|
+
/** Total wall-clock budget (default 30s). */
|
|
95
|
+
totalTimeoutMs?: number
|
|
96
|
+
/** Abort the build; checked every iteration. */
|
|
97
|
+
signal?: AbortSignal
|
|
98
|
+
/** Resume from a previous `nextBeforeSeq` instead of the newest page. */
|
|
99
|
+
startBeforeSeq?: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type HistoryIndexCode = 'COMPLETE' | 'BUDGET' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELLED'
|
|
103
|
+
|
|
104
|
+
export interface HistoryIndexResult {
|
|
105
|
+
ok: boolean
|
|
106
|
+
code: HistoryIndexCode
|
|
107
|
+
/** Questions collected so far, ascending by anchorSeq. */
|
|
108
|
+
questions: QuestionNode[]
|
|
109
|
+
/** Page count actually read. */
|
|
110
|
+
pages: number
|
|
111
|
+
/** Where to continue (exclusive) when stopped early; undefined when COMPLETE. */
|
|
112
|
+
nextBeforeSeq: number | undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const DEFAULTS = {
|
|
116
|
+
maxMessages: 100,
|
|
117
|
+
maxPages: 200,
|
|
118
|
+
totalTimeoutMs: 30_000,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function minSeq(events: readonly { event: RawEventLike }[]): number | undefined {
|
|
122
|
+
let min: number | undefined
|
|
123
|
+
for (const { event } of events) {
|
|
124
|
+
if (min === undefined || event.seq < min) min = event.seq
|
|
125
|
+
}
|
|
126
|
+
return min
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Page the raw session history backward, collecting every user question into a
|
|
131
|
+
* lightweight index. Never touches the render window.
|
|
132
|
+
*/
|
|
133
|
+
export async function buildQuestionIndex(
|
|
134
|
+
ports: HistoryIndexPorts,
|
|
135
|
+
options: HistoryIndexOptions = {},
|
|
136
|
+
): Promise<HistoryIndexResult> {
|
|
137
|
+
const cfg = { ...DEFAULTS, ...options }
|
|
138
|
+
const deadline = ports.now() + cfg.totalTimeoutMs
|
|
139
|
+
const questions: QuestionNode[] = []
|
|
140
|
+
let beforeSeq: number | undefined = cfg.startBeforeSeq
|
|
141
|
+
let pages = 0
|
|
142
|
+
|
|
143
|
+
const cancelled = (): boolean => cfg.signal?.aborted === true
|
|
144
|
+
|
|
145
|
+
while (true) {
|
|
146
|
+
if (cancelled()) return { ok: false, code: 'CANCELLED', questions, pages, nextBeforeSeq: beforeSeq }
|
|
147
|
+
if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', questions, pages, nextBeforeSeq: beforeSeq }
|
|
148
|
+
if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', questions, pages, nextBeforeSeq: beforeSeq }
|
|
149
|
+
|
|
150
|
+
const page = await ports.history(beforeSeq, cfg.maxMessages)
|
|
151
|
+
if (page === undefined) {
|
|
152
|
+
// Transient: retry a little, then give up with what we have.
|
|
153
|
+
if (pages === 0) return { ok: false, code: 'UNAVAILABLE', questions, pages, nextBeforeSeq: beforeSeq }
|
|
154
|
+
return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const { event } of page.events) {
|
|
158
|
+
const question = questionFromEvent(event)
|
|
159
|
+
if (question !== null) questions.push(question)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!page.hasMore) {
|
|
163
|
+
questions.sort((a, b) => a.anchorSeq - b.anchorSeq)
|
|
164
|
+
return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const next = minSeq(page.events)
|
|
168
|
+
if (next === undefined) {
|
|
169
|
+
// Empty page with hasMore true is anomalous; stop cleanly.
|
|
170
|
+
questions.sort((a, b) => a.anchorSeq - b.anchorSeq)
|
|
171
|
+
return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
|
|
172
|
+
}
|
|
173
|
+
beforeSeq = next
|
|
174
|
+
pages += 1
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/core/nodes.ts
CHANGED
|
@@ -96,3 +96,17 @@ export function nearestRenderable(
|
|
|
96
96
|
}
|
|
97
97
|
return best
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Merge two question sets (full-history index + live loaded window) into one
|
|
102
|
+
* deduplicated, anchorSeq-ascending list. The window may hold questions that
|
|
103
|
+
* arrived after the index was built; the index may hold questions the window
|
|
104
|
+
* has not loaded yet — union on `key`, newest live copy wins per key.
|
|
105
|
+
*/
|
|
106
|
+
export function mergeQuestions(...sources: readonly (readonly QuestionNode[])[]): QuestionNode[] {
|
|
107
|
+
const byKey = new Map<string, QuestionNode>()
|
|
108
|
+
for (const source of sources) {
|
|
109
|
+
for (const node of source) byKey.set(node.key, node)
|
|
110
|
+
}
|
|
111
|
+
return [...byKey.values()].sort((a, b) => a.anchorSeq - b.anchorSeq)
|
|
112
|
+
}
|