@anionex/dsh-turn-rewind 0.2.0 → 0.2.2
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/lib/client.js +103 -9
- package/lib/client.js.map +1 -1
- package/lib/index.js +3 -1
- package/lib/rewind-host.js +153 -148
- package/lib/types/client/index.d.ts +57 -9
- package/lib/types/rewind-host.d.ts +43 -7
- package/package.json +6 -2
- package/src/client/index.tsx +137 -20
- package/src/index.ts +3 -1
- package/src/rewind-host.ts +197 -164
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anionex/dsh-turn-rewind",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Turn-level conversation and workspace rewind for DeepSeek Harness, powered by a persistent Change Ledger",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -64,7 +64,11 @@
|
|
|
64
64
|
"dshReleases": {
|
|
65
65
|
"0.1.0-rc.8": "compatible",
|
|
66
66
|
"0.1.1-rc.1": "compatible",
|
|
67
|
-
"0.1.1-rc.2": "compatible"
|
|
67
|
+
"0.1.1-rc.2": "compatible",
|
|
68
|
+
"0.1.2-alpha.1": "compatible",
|
|
69
|
+
"0.1.2-alpha.3": "compatible",
|
|
70
|
+
"0.1.2-alpha.4": "compatible",
|
|
71
|
+
"0.1.2-alpha.5": "compatible"
|
|
68
72
|
},
|
|
69
73
|
"profiles": [
|
|
70
74
|
"web"
|
package/src/client/index.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import {
|
|
4
4
|
Button,
|
|
@@ -18,15 +18,33 @@ interface ConversationChatNodeLike {
|
|
|
18
18
|
readonly data: ConversationNodeLike
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Chat node store exposed by the Chat target. DSH 0.1.2+ publishes a mutable
|
|
23
|
+
* keyed store; older clients published a Map with the same `get` contract.
|
|
24
|
+
*/
|
|
25
|
+
interface ChatNodeStoreLike {
|
|
26
|
+
get(key: string): RewindNodeLike | undefined
|
|
27
|
+
values?(): Iterable<RewindNodeLike>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Chat projection shared by `session.chat` (0.1.1) and the `useChat` hook (0.1.2+). */
|
|
31
|
+
interface ChatSnapshotLike {
|
|
32
|
+
readonly order?: readonly string[]
|
|
33
|
+
readonly nodes?: ChatNodeStoreLike | readonly RewindNodeLike[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Snapshot seen by a session-scoped slot entry. 0.1.1 exposes the chat
|
|
38
|
+
* projection as `snapshot.chat`; 0.1.2+ moved it to the separate `useChat`
|
|
39
|
+
* hook, so the same snapshot is the chat projection itself.
|
|
40
|
+
*/
|
|
41
|
+
interface ConversationSnapshotLike extends ChatSnapshotLike {
|
|
42
|
+
readonly chat?: ChatSnapshotLike
|
|
28
43
|
}
|
|
29
44
|
|
|
45
|
+
/** Selector hook shape shared by `useSession` and `useChat`. */
|
|
46
|
+
type SnapshotSelectorHook = <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T
|
|
47
|
+
|
|
30
48
|
type RewindNodeLike = ConversationNodeLike | ConversationChatNodeLike
|
|
31
49
|
|
|
32
50
|
interface RewindMatch {
|
|
@@ -43,7 +61,8 @@ interface RewindMessageActionProps {
|
|
|
43
61
|
interface RewindPortalBridgeProps {
|
|
44
62
|
readonly sessionId: string
|
|
45
63
|
readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
|
|
46
|
-
readonly useSession:
|
|
64
|
+
readonly useSession: SnapshotSelectorHook
|
|
65
|
+
readonly useChat?: SnapshotSelectorHook
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
interface RewindPortalTarget {
|
|
@@ -281,9 +300,50 @@ export function apply(ctx: ClientContextLike): void {
|
|
|
281
300
|
}, TurnRewindSettingsCard))
|
|
282
301
|
}
|
|
283
302
|
|
|
303
|
+
/** Narrow a chat node source to the keyed store form. */
|
|
304
|
+
function isNodeStore(value: ChatNodeStoreLike | readonly RewindNodeLike[]): value is ChatNodeStoreLike {
|
|
305
|
+
return typeof (value as ChatNodeStoreLike).get === 'function'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resolve the ordered chat node list from one chat projection.
|
|
310
|
+
*
|
|
311
|
+
* Both inputs keep a stable identity across renders: `order` is republished only
|
|
312
|
+
* when the node set changes and the store is a mutable handle, so the caller can
|
|
313
|
+
* memoize the list instead of allocating a new array on every render (a fresh
|
|
314
|
+
* array per render makes `useSyncExternalStore` loop forever).
|
|
315
|
+
* @param order - ordered node keys, or null when the projection is absent.
|
|
316
|
+
* @param store - keyed node store, a legacy node array, or null.
|
|
317
|
+
* @returns the chat nodes in render order.
|
|
318
|
+
*/
|
|
319
|
+
export function collectChatNodes(
|
|
320
|
+
order: readonly string[] | null,
|
|
321
|
+
store: ChatNodeStoreLike | readonly RewindNodeLike[] | null,
|
|
322
|
+
): readonly RewindNodeLike[] {
|
|
323
|
+
if (store === null) return []
|
|
324
|
+
if (Array.isArray(store)) return store as readonly RewindNodeLike[]
|
|
325
|
+
if (order !== null && isNodeStore(store)) {
|
|
326
|
+
const nodes: RewindNodeLike[] = []
|
|
327
|
+
for (const key of order) {
|
|
328
|
+
const node = store.get(key)
|
|
329
|
+
if (node !== undefined) nodes.push(node)
|
|
330
|
+
}
|
|
331
|
+
return nodes
|
|
332
|
+
}
|
|
333
|
+
return typeof store.values === 'function' ? Array.from(store.values() as Iterable<RewindNodeLike>) : []
|
|
334
|
+
}
|
|
335
|
+
|
|
284
336
|
/** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
|
|
285
|
-
export function RewindMessagePortals({ sessionId, openRestoredSession, useSession }: RewindPortalBridgeProps): ReactNode {
|
|
286
|
-
|
|
337
|
+
export function RewindMessagePortals({ sessionId, openRestoredSession, useSession, useChat }: RewindPortalBridgeProps): ReactNode {
|
|
338
|
+
// DSH 0.1.2+ moved the Chat projection out of the Session snapshot into its own
|
|
339
|
+
// session-scoped `useChat` hook; `snapshot.chat` remains the 0.1.1 shape.
|
|
340
|
+
const readSnapshot = useChat ?? useSession
|
|
341
|
+
const chatOf = (snapshot: ConversationSnapshotLike): ChatSnapshotLike => snapshot.chat ?? snapshot
|
|
342
|
+
const chatOrder = readSnapshot<readonly string[] | null>(snapshot => chatOf(snapshot).order ?? null)
|
|
343
|
+
const chatStore = readSnapshot<ChatNodeStoreLike | readonly RewindNodeLike[] | null>(
|
|
344
|
+
snapshot => chatOf(snapshot).nodes ?? null,
|
|
345
|
+
)
|
|
346
|
+
const nodes = useMemo(() => collectChatNodes(chatOrder, chatStore), [chatOrder, chatStore])
|
|
287
347
|
const [targets, setTargets] = useState<readonly RewindPortalTarget[]>([])
|
|
288
348
|
|
|
289
349
|
useLayoutEffect(() => {
|
|
@@ -533,7 +593,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
|
|
|
533
593
|
{preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
|
|
534
594
|
{preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能、记录已超过保留期限,或已关闭自动检查点。仍可只回溯消息。</p>}
|
|
535
595
|
{preview?.status === 'skipped' && <p className="dcl-rewind-status">为避免阻塞消息发送,本轮没有自动保存文件:{preview.reason}仍可只回溯消息。</p>}
|
|
536
|
-
{preview?.status === 'failed' && <p className="dcl-rewind-error"
|
|
596
|
+
{preview?.status === 'failed' && <p className="dcl-rewind-error">{explainCheckpointFailure(preview.error)}</p>}
|
|
537
597
|
{preview !== null && (
|
|
538
598
|
<div className="dcl-rewind-options">
|
|
539
599
|
<label className="dcl-rewind-option" data-selected={mode === 'both'} data-disabled={applying || !hasFileChanges}>
|
|
@@ -1017,9 +1077,12 @@ function collectPortalTargets(nodes: readonly RewindNodeLike[]): readonly Rewind
|
|
|
1017
1077
|
const target = selectRewindMessageTarget(value)
|
|
1018
1078
|
if (target === null) continue
|
|
1019
1079
|
const row = rows.get(target.rowKey)
|
|
1020
|
-
const messageRoot = row?.querySelector
|
|
1021
|
-
|
|
1022
|
-
|
|
1080
|
+
const messageRoot = row?.querySelector?.<HTMLElement>('[data-time-hover-root="true"]')
|
|
1081
|
+
// Match the actions container by class or last child element
|
|
1082
|
+
const actions = (typeof messageRoot?.querySelector === 'function'
|
|
1083
|
+
? messageRoot.querySelector<HTMLElement>('[class*="actions"]')
|
|
1084
|
+
: undefined) ?? messageRoot?.lastElementChild
|
|
1085
|
+
if (!(actions instanceof HTMLElement)) continue
|
|
1023
1086
|
targets.push({ container: actions, matched: target.matched })
|
|
1024
1087
|
}
|
|
1025
1088
|
return targets
|
|
@@ -1057,15 +1120,40 @@ async function openSessionWithDraft(ctx: ClientContextLike, sessionId: string, p
|
|
|
1057
1120
|
throw lastError
|
|
1058
1121
|
}
|
|
1059
1122
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1123
|
+
/**
|
|
1124
|
+
* Read one rewind response body without ever leaking a raw parse error.
|
|
1125
|
+
*
|
|
1126
|
+
* A missing route, a restarted Host, or a proxy answering before the plugin
|
|
1127
|
+
* loads all produce a body that is not the plugin's JSON envelope; those must
|
|
1128
|
+
* surface as an explained failure instead of `Failed to execute 'json' …`.
|
|
1129
|
+
* @param response - fetch response from the rewind endpoint.
|
|
1130
|
+
* @returns the decoded JSON body.
|
|
1131
|
+
*/
|
|
1132
|
+
export async function responseJson(response: Response): Promise<unknown> {
|
|
1133
|
+
const text = await response.text()
|
|
1134
|
+
let value: unknown
|
|
1135
|
+
try {
|
|
1136
|
+
value = text === '' ? undefined : JSON.parse(text) as unknown
|
|
1137
|
+
} catch {
|
|
1138
|
+
throw new RewindRequestError(
|
|
1139
|
+
response.ok ? 'REWIND_INVALID_RESPONSE' : 'REWIND_ENDPOINT_UNAVAILABLE',
|
|
1140
|
+
`回退服务返回了无法解析的内容(HTTP ${String(response.status)})。`,
|
|
1141
|
+
)
|
|
1142
|
+
}
|
|
1143
|
+
const record = value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1144
|
+
? value as Record<string, unknown>
|
|
1145
|
+
: undefined
|
|
1062
1146
|
if (!response.ok) {
|
|
1063
|
-
const record = recordOf(value)
|
|
1064
1147
|
throw new RewindRequestError(
|
|
1065
|
-
typeof record
|
|
1066
|
-
|
|
1148
|
+
typeof record?.code === 'string'
|
|
1149
|
+
? record.code
|
|
1150
|
+
: response.status === 404 ? 'REWIND_ENDPOINT_UNAVAILABLE' : 'REWIND_FAILED',
|
|
1151
|
+
typeof record?.error === 'string' ? record.error : `请求失败:${String(response.status)}`,
|
|
1067
1152
|
)
|
|
1068
1153
|
}
|
|
1154
|
+
if (value === undefined) {
|
|
1155
|
+
throw new RewindRequestError('REWIND_INVALID_RESPONSE', '回退服务返回了空响应。')
|
|
1156
|
+
}
|
|
1069
1157
|
return value
|
|
1070
1158
|
}
|
|
1071
1159
|
|
|
@@ -1123,6 +1211,8 @@ function RewindIcon({ size }: { readonly size: number }): ReactNode {
|
|
|
1123
1211
|
function friendlyError(error: unknown): string {
|
|
1124
1212
|
if (!(error instanceof RewindRequestError)) return messageOf(error)
|
|
1125
1213
|
switch (error.code) {
|
|
1214
|
+
case 'REWIND_ENDPOINT_UNAVAILABLE': return '回退服务没有响应。请确认 @anionex/dsh-turn-rewind 已挂载到当前 DSH,并重启 DSH 后重试。'
|
|
1215
|
+
case 'REWIND_INVALID_RESPONSE': return '回退服务返回了无法解析的内容。请重启 DSH 后重试。'
|
|
1126
1216
|
case 'PLAN_STALE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
|
|
1127
1217
|
case 'PLAN_STALE_REPOSITORY': return 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。'
|
|
1128
1218
|
case 'WORKSPACE_IN_USE': return '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。'
|
|
@@ -1138,6 +1228,33 @@ function friendlyError(error: unknown): string {
|
|
|
1138
1228
|
}
|
|
1139
1229
|
}
|
|
1140
1230
|
|
|
1231
|
+
/**
|
|
1232
|
+
* Explain one recorded checkpoint failure in user terms.
|
|
1233
|
+
*
|
|
1234
|
+
* The Host records the raw `[CODE] diagnostic` line; a non-Git project
|
|
1235
|
+
* directory is the common case and deserves a plain sentence instead of a
|
|
1236
|
+
* `git rev-parse` transcript.
|
|
1237
|
+
* @param message - recorded checkpoint failure message.
|
|
1238
|
+
* @returns one user-facing sentence.
|
|
1239
|
+
*/
|
|
1240
|
+
export function explainCheckpointFailure(message: string): string {
|
|
1241
|
+
const code = /^\[([A-Z_]+)\]/.exec(message)?.[1]
|
|
1242
|
+
switch (code) {
|
|
1243
|
+
case 'GIT_COMMAND_FAILED':
|
|
1244
|
+
return /not a git repository/i.test(message)
|
|
1245
|
+
? '这个项目目录不是 Git 仓库,回退功能无法保存文件检查点。仍可只回溯消息。'
|
|
1246
|
+
: `无法读取这个项目目录的 Git 状态:${message}仍可只回溯消息。`
|
|
1247
|
+
case 'FILE_TOO_LARGE':
|
|
1248
|
+
case 'SNAPSHOT_TOO_LARGE':
|
|
1249
|
+
case 'TOO_MANY_FILES':
|
|
1250
|
+
return '本轮有文件超过检查点的大小或数量上限,没有保存文件检查点。仍可只回溯消息。'
|
|
1251
|
+
case 'INVALID_PATH':
|
|
1252
|
+
return '项目目录里有无法安全保存的路径(例如嵌套的独立 Git 仓库),没有保存文件检查点。仍可只回溯消息。'
|
|
1253
|
+
default:
|
|
1254
|
+
return `没能保存这条消息发送前的文件:${message}仍可只回溯消息。`
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1141
1258
|
function messageOf(error: unknown): string {
|
|
1142
1259
|
return error instanceof Error ? error.message : String(error)
|
|
1143
1260
|
}
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,9 @@ export class ChangeLedgerService {
|
|
|
30
30
|
this.engine = new ChangeLedgerEngine(config)
|
|
31
31
|
const checkpoints = new TurnCheckpointCoordinator(this.engine)
|
|
32
32
|
ctx.inject(['agents'], (scope: Context) => { checkpoints.install(scope) })
|
|
33
|
-
|
|
33
|
+
// `apiProxy` is deliberately absent: DSH 0.1.2-alpha carriers (DSH Desktop 2.x)
|
|
34
|
+
// provide `sessionController` instead, and the route must exist on both.
|
|
35
|
+
ctx.inject(['webServer', 'sessions', 'sessionQuery', 'agents'], (scope: Context) => {
|
|
34
36
|
installRewindHttp(scope, this.engine, checkpoints)
|
|
35
37
|
installManageHttp(scope, this.engine)
|
|
36
38
|
})
|