dsh-session-hover-preview 0.1.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 +30 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +268 -0
- package/lib/index.js +182 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# dsh-session-hover-preview
|
|
2
|
+
|
|
3
|
+
本地 DeepSeek Harness Web 插件,为右侧当前会话历史增加 Codex 风格的消息导航条。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 在右侧会话历史区域的左侧边缘、页面垂直居中位置显示消息导航条;定位和交互均不依赖左侧 Session 列表。
|
|
8
|
+
- 每条横线对应一条真实用户消息,默认等长;Host 会分页读取完整 Session 历史。
|
|
9
|
+
- Hover 或键盘聚焦某条横线时,该条最长,并按距离向上下两侧逐渐缩短。
|
|
10
|
+
- 点击横线会滚动到对应用户消息;尚未渲染的旧消息会先通过“加载更早”补齐。
|
|
11
|
+
- 切换右侧当前会话或历史更新时会取消旧请求并刷新横线。
|
|
12
|
+
- Markdown 只用于生成安全纯文本标签;触屏和窄屏自动禁用。
|
|
13
|
+
- 只读调用官方 `ApiProxy.sessions.history`,不会恢复或发布 Agent。
|
|
14
|
+
|
|
15
|
+
## 本地安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cd ~/.dsh
|
|
19
|
+
dsh plugin --profile web add /Users/g/gits/for-nothing/dsh-session-hover-preview
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
安装后刷新现有的 `http://127.0.0.1:3080`。如果 `dsh web` 已经运行,Host 端新增路由通常需要重启该进程;单独重写 `lib/client.js` 时,只有配套构建 watcher 正在运行才会触发 client HMR。
|
|
23
|
+
|
|
24
|
+
## 本地检查
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node --test test/index.test.js
|
|
28
|
+
node --check lib/index.js
|
|
29
|
+
node --check lib/client.js
|
|
30
|
+
```
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-session-hover-preview',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
const module = { exports: {} }
|
|
5
|
+
const exports = module.exports
|
|
6
|
+
|
|
7
|
+
const inject = ['sessions']
|
|
8
|
+
const REFRESH_DELAY = 220
|
|
9
|
+
const DEFAULT_LINE_WIDTH = 10
|
|
10
|
+
const MAX_LINE_WIDTH = 30
|
|
11
|
+
const MIN_LINE_WIDTH = 8
|
|
12
|
+
const WIDTH_STEP = 4
|
|
13
|
+
const MAX_LOAD_OLDER_PAGES = 100
|
|
14
|
+
|
|
15
|
+
const css = `
|
|
16
|
+
[data-dsh-session-history-rail]{position:fixed;z-index:110;display:flex;box-sizing:border-box;width:46px;max-height:min(60vh,480px);padding:8px 4px;flex-direction:column;align-items:flex-start;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;opacity:.62;transition:opacity .16s ease,transform .16s ease;transform:translateX(0)}
|
|
17
|
+
[data-dsh-session-history-rail]::-webkit-scrollbar{display:none}
|
|
18
|
+
[data-dsh-session-history-rail]:hover,[data-dsh-session-history-rail]:focus-within{opacity:.9}
|
|
19
|
+
[data-dsh-session-history-rail][data-hidden]{pointer-events:none;opacity:0;transform:translateX(-4px)}
|
|
20
|
+
[data-dsh-session-history-line]{position:relative;display:block;box-sizing:border-box;width:38px;height:9px;min-height:9px;padding:0;flex:0 0 9px;cursor:pointer;background:transparent;border:0}
|
|
21
|
+
[data-dsh-session-history-line]::before{position:absolute;top:3px;left:0;width:var(--dsh-history-line-width,10px);height:2px;content:"";background:var(--dsw-alias-label-tertiary,#8d9199);border-radius:1px;transition:width .13s ease,background .13s ease}
|
|
22
|
+
[data-dsh-session-history-line]:hover,[data-dsh-session-history-line]:focus-visible{outline:none}
|
|
23
|
+
[data-dsh-session-history-line]:hover::before,[data-dsh-session-history-line]:focus-visible::before{background:var(--dsw-alias-label-primary,#222)}
|
|
24
|
+
@media (prefers-reduced-motion:reduce){[data-dsh-session-history-rail],[data-dsh-session-history-line]::before{transition:none}}
|
|
25
|
+
@media (max-width:760px),(hover:none){[data-dsh-session-history-rail]{display:none!important}}
|
|
26
|
+
`
|
|
27
|
+
|
|
28
|
+
function currentSession(ctx) {
|
|
29
|
+
const state = ctx.sessions.list.getSnapshot()
|
|
30
|
+
const summary = state.current ? state.byId[state.current] : undefined
|
|
31
|
+
return summary && !summary.blank ? summary : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function conversationViewport() {
|
|
35
|
+
let element = document.querySelector('[data-slot="conversation.view"]')?.parentElement
|
|
36
|
+
while (element) {
|
|
37
|
+
const rect = element.getBoundingClientRect()
|
|
38
|
+
if (['auto', 'scroll'].includes(getComputedStyle(element).overflowY) && rect.width > 0 && rect.height > 0) return element
|
|
39
|
+
element = element.parentElement
|
|
40
|
+
}
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function loadMessages(sessionId, signal) {
|
|
45
|
+
const response = await fetch('/session-hover-preview', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'content-type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ sessionId }),
|
|
49
|
+
signal,
|
|
50
|
+
})
|
|
51
|
+
const body = await response.json()
|
|
52
|
+
if (!response.ok || !body.ok) throw new Error(body.error || 'history unavailable')
|
|
53
|
+
return Array.isArray(body.messages)
|
|
54
|
+
? body.messages.filter(message => message && typeof message.id === 'string' && typeof message.text === 'string')
|
|
55
|
+
: []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function apply(ctx) {
|
|
59
|
+
ctx.effect(() => {
|
|
60
|
+
const style = document.createElement('style')
|
|
61
|
+
style.dataset.plugin = 'dsh-session-hover-preview'
|
|
62
|
+
style.textContent = css
|
|
63
|
+
document.head.appendChild(style)
|
|
64
|
+
|
|
65
|
+
const rail = document.createElement('nav')
|
|
66
|
+
rail.setAttribute('data-dsh-session-history-rail', '')
|
|
67
|
+
rail.setAttribute('aria-label', '当前会话消息导航')
|
|
68
|
+
rail.setAttribute('data-hidden', '')
|
|
69
|
+
document.body.appendChild(rail)
|
|
70
|
+
|
|
71
|
+
let request
|
|
72
|
+
let refreshTimer
|
|
73
|
+
let refreshToken = 0
|
|
74
|
+
let currentSessionId = currentSession(ctx)?.id
|
|
75
|
+
let observedViewport
|
|
76
|
+
|
|
77
|
+
const resetLines = () => {
|
|
78
|
+
for (const line of rail.children) line.style.setProperty('--dsh-history-line-width', `${DEFAULT_LINE_WIDTH}px`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const focusLines = activeIndex => {
|
|
82
|
+
for (const [index, line] of [...rail.children].entries()) {
|
|
83
|
+
const width = Math.max(MIN_LINE_WIDTH, MAX_LINE_WIDTH - Math.abs(index - activeIndex) * WIDTH_STEP)
|
|
84
|
+
line.style.setProperty('--dsh-history-line-width', `${width}px`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const placeRail = () => {
|
|
89
|
+
const summary = currentSession(ctx)
|
|
90
|
+
const viewport = conversationViewport()
|
|
91
|
+
const rect = viewport?.getBoundingClientRect()
|
|
92
|
+
if (!summary || rail.childElementCount === 0 || !rect || rect.width === 0 || rect.height === 0) {
|
|
93
|
+
rail.setAttribute('data-hidden', '')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const height = rail.offsetHeight
|
|
97
|
+
const left = Math.max(12, Math.min(rect.left + 12, window.innerWidth - rail.offsetWidth - 12))
|
|
98
|
+
const center = window.innerHeight / 2
|
|
99
|
+
const top = Math.max(rect.top + 12, Math.min(center - height / 2, rect.bottom - height - 12))
|
|
100
|
+
rail.style.left = `${left}px`
|
|
101
|
+
rail.style.top = `${top}px`
|
|
102
|
+
rail.removeAttribute('data-hidden')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const findMessageElement = messageId => [...document.querySelectorAll('[data-chat-flow-kind="user"][data-chat-anchor-key]')]
|
|
106
|
+
.find(element => element.getAttribute('data-chat-anchor-key')?.endsWith(`input-message${messageId}`))
|
|
107
|
+
|
|
108
|
+
const olderPageControl = () => {
|
|
109
|
+
const column = document.querySelector('[data-slot="conversation.view"] [data-chat-flow]')
|
|
110
|
+
const first = column?.firstElementChild
|
|
111
|
+
if (!column || !first || first.hasAttribute('data-chat-flow-kind')) return undefined
|
|
112
|
+
const button = [...first.children].find(child => child instanceof HTMLButtonElement)
|
|
113
|
+
return button ? { button, column } : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const waitForOlderPage = (column, previousCount, messageId) => new Promise(resolve => {
|
|
117
|
+
let finished = false
|
|
118
|
+
const done = progressed => {
|
|
119
|
+
if (finished) return
|
|
120
|
+
finished = true
|
|
121
|
+
observer.disconnect()
|
|
122
|
+
clearTimeout(timeout)
|
|
123
|
+
resolve(progressed)
|
|
124
|
+
}
|
|
125
|
+
const observer = new MutationObserver(() => {
|
|
126
|
+
const count = column.querySelectorAll('[data-chat-flow-kind="user"]').length
|
|
127
|
+
if (findMessageElement(messageId) || count > previousCount || !olderPageControl()) done(true)
|
|
128
|
+
})
|
|
129
|
+
const timeout = setTimeout(() => done(false), 10000)
|
|
130
|
+
observer.observe(column, { childList: true, subtree: true })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const waitForStableLayout = async element => {
|
|
134
|
+
let previousTop
|
|
135
|
+
let stableFrames = 0
|
|
136
|
+
for (let frame = 0; frame < 30 && stableFrames < 2; frame += 1) {
|
|
137
|
+
await new Promise(resolve => requestAnimationFrame(resolve))
|
|
138
|
+
const top = element.getBoundingClientRect().top
|
|
139
|
+
stableFrames = previousTop !== undefined && Math.abs(top - previousTop) < .5 ? stableFrames + 1 : 0
|
|
140
|
+
previousTop = top
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const scrollToMessage = async messageId => {
|
|
145
|
+
const sessionId = currentSession(ctx)?.id
|
|
146
|
+
if (!sessionId) return
|
|
147
|
+
let loadedOlder = false
|
|
148
|
+
for (let page = 0; page < MAX_LOAD_OLDER_PAGES; page += 1) {
|
|
149
|
+
const target = findMessageElement(messageId)
|
|
150
|
+
if (target) {
|
|
151
|
+
if (loadedOlder) await waitForStableLayout(target)
|
|
152
|
+
target.scrollIntoView({
|
|
153
|
+
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
|
154
|
+
block: 'center',
|
|
155
|
+
})
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
if (currentSession(ctx)?.id !== sessionId) return
|
|
159
|
+
const control = olderPageControl()
|
|
160
|
+
if (!control) return
|
|
161
|
+
const previousCount = control.column.querySelectorAll('[data-chat-flow-kind="user"]').length
|
|
162
|
+
const loaded = waitForOlderPage(control.column, previousCount, messageId)
|
|
163
|
+
control.button.click()
|
|
164
|
+
if (!await loaded) return
|
|
165
|
+
loadedOlder = true
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const renderMessages = messages => {
|
|
170
|
+
rail.replaceChildren()
|
|
171
|
+
for (const [index, message] of messages.entries()) {
|
|
172
|
+
const line = document.createElement('button')
|
|
173
|
+
line.type = 'button'
|
|
174
|
+
line.setAttribute('data-dsh-session-history-line', '')
|
|
175
|
+
line.setAttribute('data-message-id', message.id)
|
|
176
|
+
line.setAttribute('aria-label', `跳转到第 ${index + 1} 条用户消息:${message.text}`)
|
|
177
|
+
line.style.setProperty('--dsh-history-line-width', `${DEFAULT_LINE_WIDTH}px`)
|
|
178
|
+
line.addEventListener('pointerenter', () => focusLines(index))
|
|
179
|
+
line.addEventListener('focus', () => focusLines(index))
|
|
180
|
+
line.addEventListener('click', event => {
|
|
181
|
+
event.stopPropagation()
|
|
182
|
+
void scrollToMessage(message.id)
|
|
183
|
+
})
|
|
184
|
+
rail.appendChild(line)
|
|
185
|
+
}
|
|
186
|
+
placeRail()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const refresh = async () => {
|
|
190
|
+
refreshTimer = undefined
|
|
191
|
+
const summary = currentSession(ctx)
|
|
192
|
+
if (!summary || !conversationViewport()) {
|
|
193
|
+
request?.abort()
|
|
194
|
+
rail.replaceChildren()
|
|
195
|
+
rail.setAttribute('data-hidden', '')
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
request?.abort()
|
|
199
|
+
request = new AbortController()
|
|
200
|
+
const ownToken = ++refreshToken
|
|
201
|
+
try {
|
|
202
|
+
const messages = await loadMessages(summary.id, request.signal)
|
|
203
|
+
if (ownToken !== refreshToken || currentSession(ctx)?.id !== summary.id) return
|
|
204
|
+
renderMessages(messages)
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error?.name === 'AbortError' || ownToken !== refreshToken) return
|
|
207
|
+
rail.replaceChildren()
|
|
208
|
+
rail.setAttribute('data-hidden', '')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const scheduleRefresh = (delay = REFRESH_DELAY) => {
|
|
213
|
+
clearTimeout(refreshTimer)
|
|
214
|
+
refreshTimer = setTimeout(() => { void refresh() }, delay)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const resizeObserver = new ResizeObserver(placeRail)
|
|
218
|
+
const observeViewport = () => {
|
|
219
|
+
const viewport = conversationViewport()
|
|
220
|
+
if (viewport === observedViewport) return
|
|
221
|
+
resizeObserver.disconnect()
|
|
222
|
+
observedViewport = viewport
|
|
223
|
+
if (viewport) {
|
|
224
|
+
resizeObserver.observe(viewport)
|
|
225
|
+
if (rail.childElementCount === 0) scheduleRefresh(0)
|
|
226
|
+
}
|
|
227
|
+
placeRail()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
rail.addEventListener('pointerleave', resetLines)
|
|
231
|
+
rail.addEventListener('focusout', event => {
|
|
232
|
+
if (!(event.relatedTarget instanceof Node) || !rail.contains(event.relatedTarget)) resetLines()
|
|
233
|
+
})
|
|
234
|
+
window.addEventListener('resize', placeRail)
|
|
235
|
+
const mutationObserver = new MutationObserver(observeViewport)
|
|
236
|
+
mutationObserver.observe(document.body, { childList: true, subtree: true })
|
|
237
|
+
const unsubscribe = ctx.sessions.list.subscribe(() => {
|
|
238
|
+
const nextSessionId = currentSession(ctx)?.id
|
|
239
|
+
if (nextSessionId !== currentSessionId) {
|
|
240
|
+
currentSessionId = nextSessionId
|
|
241
|
+
request?.abort()
|
|
242
|
+
rail.replaceChildren()
|
|
243
|
+
rail.setAttribute('data-hidden', '')
|
|
244
|
+
scheduleRefresh(0)
|
|
245
|
+
} else scheduleRefresh()
|
|
246
|
+
})
|
|
247
|
+
observeViewport()
|
|
248
|
+
scheduleRefresh(0)
|
|
249
|
+
|
|
250
|
+
return () => {
|
|
251
|
+
clearTimeout(refreshTimer)
|
|
252
|
+
request?.abort()
|
|
253
|
+
unsubscribe()
|
|
254
|
+
mutationObserver.disconnect()
|
|
255
|
+
resizeObserver.disconnect()
|
|
256
|
+
rail.removeEventListener('pointerleave', resetLines)
|
|
257
|
+
window.removeEventListener('resize', placeRail)
|
|
258
|
+
style.remove()
|
|
259
|
+
rail.remove()
|
|
260
|
+
}
|
|
261
|
+
}, 'dsh-session-hover-preview: mount')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
exports.inject = inject
|
|
265
|
+
exports.apply = apply
|
|
266
|
+
return module.exports
|
|
267
|
+
},
|
|
268
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half for the session hover-preview plugin.
|
|
3
|
+
*
|
|
4
|
+
* It exposes one same-origin, read-only endpoint backed by the official
|
|
5
|
+
* ApiProxy history seam. Reading history does not resume or publish an Agent.
|
|
6
|
+
*/
|
|
7
|
+
export const name = 'dsh-session-hover-preview'
|
|
8
|
+
export const inject = ['webServer', 'apiProxy']
|
|
9
|
+
|
|
10
|
+
const MAX_BODY_BYTES = 16 * 1024
|
|
11
|
+
const HISTORY_PAGE_MESSAGES = 50
|
|
12
|
+
const PREVIEW_CHARS = 360
|
|
13
|
+
|
|
14
|
+
function writeJson(res, status, body) {
|
|
15
|
+
res.writeHead(status, {
|
|
16
|
+
'content-type': 'application/json; charset=utf-8',
|
|
17
|
+
'cache-control': 'no-store',
|
|
18
|
+
})
|
|
19
|
+
res.end(JSON.stringify(body))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isLoopback(hostname) {
|
|
23
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
24
|
+
const parts = hostname.split('.')
|
|
25
|
+
return parts.length === 4
|
|
26
|
+
&& parts[0] === '127'
|
|
27
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function trustedRequest(req) {
|
|
31
|
+
const host = typeof req.headers.host === 'string' ? req.headers.host : undefined
|
|
32
|
+
if (!host) return false
|
|
33
|
+
let hostUrl
|
|
34
|
+
try { hostUrl = new URL(`http://${host}`) } catch { return false }
|
|
35
|
+
if (!isLoopback(hostUrl.hostname)) return false
|
|
36
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
37
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined
|
|
38
|
+
if (!origin) return true
|
|
39
|
+
try { return new URL(origin).host === hostUrl.host } catch { return false }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readBody(req) {
|
|
43
|
+
const chunks = []
|
|
44
|
+
let size = 0
|
|
45
|
+
for await (const chunk of req) {
|
|
46
|
+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk)
|
|
47
|
+
size += bytes.length
|
|
48
|
+
if (size > MAX_BODY_BYTES) throw new Error('request body too large')
|
|
49
|
+
chunks.push(bytes)
|
|
50
|
+
}
|
|
51
|
+
if (chunks.length === 0) return {}
|
|
52
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizedText(value) {
|
|
56
|
+
return value
|
|
57
|
+
.replace(/```[\s\S]*?```/g, match => match.replace(/^```[^\n]*\n?|```$/g, ' '))
|
|
58
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
59
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
60
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
61
|
+
.replace(/^\s{0,3}(?:#{1,6}|>|[-*+] |\d+[.)] )\s*/gm, '')
|
|
62
|
+
.replace(/[*_~]/g, '')
|
|
63
|
+
.replace(/\s+/g, ' ')
|
|
64
|
+
.trim()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function textFromBlocks(blocks) {
|
|
68
|
+
if (!Array.isArray(blocks)) return ''
|
|
69
|
+
return normalizedText(blocks
|
|
70
|
+
.filter(block => block && block.type === 'text' && typeof block.text === 'string')
|
|
71
|
+
.map(block => block.text)
|
|
72
|
+
.join('\n'))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clamp(value) {
|
|
76
|
+
const chars = Array.from(value)
|
|
77
|
+
return chars.length <= PREVIEW_CHARS ? value : `${chars.slice(0, PREVIEW_CHARS).join('')}…`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function previewFrom(events) {
|
|
81
|
+
const turns = []
|
|
82
|
+
let current
|
|
83
|
+
for (const entry of events) {
|
|
84
|
+
const event = entry?.event
|
|
85
|
+
if (!event || typeof event !== 'object') continue
|
|
86
|
+
if (event.type === 'user/message' && event.data?.source?.kind === 'user') {
|
|
87
|
+
const text = textFromBlocks(event.data.content)
|
|
88
|
+
if (!text) continue
|
|
89
|
+
current = { user: text, assistant: '' }
|
|
90
|
+
turns.push(current)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (event.type === 'assistant/message' && current) {
|
|
94
|
+
const text = textFromBlocks(event.data?.message?.content)
|
|
95
|
+
if (text) current.assistant = text
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const complete = turns.findLast(turn => turn.user && turn.assistant)
|
|
99
|
+
const latest = turns.at(-1)
|
|
100
|
+
const selected = complete ?? latest ?? { user: '', assistant: '' }
|
|
101
|
+
return { user: clamp(selected.user), assistant: clamp(selected.assistant) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function userMessagesFrom(events) {
|
|
105
|
+
return events.flatMap(entry => {
|
|
106
|
+
const event = entry?.event
|
|
107
|
+
if (event?.type !== 'user/message' || event.data?.source?.kind !== 'user' || typeof event.data.id !== 'string') return []
|
|
108
|
+
const text = textFromBlocks(event.data.content)
|
|
109
|
+
return text ? [{ id: event.data.id, text: clamp(text) }] : []
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readAllHistory(apiProxy, sessionId) {
|
|
114
|
+
const pages = []
|
|
115
|
+
let beforeSeq
|
|
116
|
+
while (true) {
|
|
117
|
+
const response = await apiProxy.sessions.history({
|
|
118
|
+
rpcId: crypto.randomUUID(),
|
|
119
|
+
payload: {
|
|
120
|
+
sessionId,
|
|
121
|
+
maxMessages: HISTORY_PAGE_MESSAGES,
|
|
122
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
if (!response.result.ok) return response.result
|
|
126
|
+
const { events, hasMore } = response.result.value
|
|
127
|
+
pages.unshift(events)
|
|
128
|
+
if (!hasMore) return { ok: true, value: pages.flat() }
|
|
129
|
+
const nextBeforeSeq = events[0]?.event?.seq
|
|
130
|
+
if (typeof nextBeforeSeq !== 'number' || nextBeforeSeq === beforeSeq) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: { code: 'history-pagination-invalid', message: 'history pagination did not advance' },
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
beforeSeq = nextBeforeSeq
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function apply(ctx) {
|
|
141
|
+
ctx.effect(() => ctx.webServer.register({
|
|
142
|
+
kind: 'exact',
|
|
143
|
+
path: '/session-hover-preview',
|
|
144
|
+
handler: async (req, res) => {
|
|
145
|
+
if (!trustedRequest(req)) {
|
|
146
|
+
writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (req.method !== 'POST') {
|
|
150
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const payload = await readBody(req)
|
|
155
|
+
if (typeof payload.sessionId !== 'string' || payload.sessionId.length === 0) {
|
|
156
|
+
writeJson(res, 400, { ok: false, error: 'sessionId is required' })
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const history = await readAllHistory(ctx.apiProxy, payload.sessionId)
|
|
160
|
+
if (!history.ok) {
|
|
161
|
+
writeJson(res, history.error.code === 'session-not-found' ? 404 : 500, {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: history.error.message,
|
|
164
|
+
})
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
writeJson(res, 200, {
|
|
168
|
+
ok: true,
|
|
169
|
+
preview: previewFrom(history.value),
|
|
170
|
+
messages: userMessagesFrom(history.value),
|
|
171
|
+
})
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const status = error instanceof SyntaxError
|
|
174
|
+
? 400
|
|
175
|
+
: error instanceof Error && error.message === 'request body too large'
|
|
176
|
+
? 413
|
|
177
|
+
: 500
|
|
178
|
+
writeJson(res, status, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
}), 'dsh-session-hover-preview: route')
|
|
182
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-session-hover-preview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codex-style user-message navigation for the current DeepSeek Harness conversation history",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"cordis.patch.yml",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"registry": "https://registry.npmjs.org/"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./lib/index.js",
|
|
18
|
+
"./client": "./lib/client.js",
|
|
19
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"dsh": {
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
},
|
|
26
|
+
"client": {
|
|
27
|
+
"inject": [
|
|
28
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-workspace"
|
|
30
|
+
],
|
|
31
|
+
"platform": "web"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
36
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-workspace": "^0.1.1-rc.2",
|
|
38
|
+
"@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
|
|
39
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2"
|
|
40
|
+
}
|
|
41
|
+
}
|