@anionex/dsh-turn-rewind 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/LICENSE +28 -0
- package/README.md +182 -0
- package/README.zh.md +180 -0
- package/SECURITY.md +53 -0
- package/cordis.patch.yml +4 -0
- package/docs/FORMAT.md +101 -0
- package/docs/IMPLEMENTATION_PLAN.md +56 -0
- package/docs/assets/turn-rewind-action.png +0 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +473 -0
- package/lib/client.js.map +1 -0
- package/lib/engine.js +616 -0
- package/lib/errors.js +20 -0
- package/lib/git.js +155 -0
- package/lib/index.js +71 -0
- package/lib/path-utils.js +262 -0
- package/lib/rewind-host.js +421 -0
- package/lib/snapshot.js +183 -0
- package/lib/store.js +391 -0
- package/lib/types/client/index.d.ts +81 -0
- package/lib/types/engine.d.ts +90 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/git.d.ts +14 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/path-utils.d.ts +34 -0
- package/lib/types/rewind-host.d.ts +137 -0
- package/lib/types/snapshot.d.ts +35 -0
- package/lib/types/store.d.ts +39 -0
- package/lib/types/types.d.ts +177 -0
- package/lib/types/validate.d.ts +11 -0
- package/lib/types.js +2 -0
- package/lib/validate.js +242 -0
- package/package.json +91 -0
- package/src/client/index.tsx +648 -0
- package/src/client/platform.d.ts +27 -0
- package/src/engine.ts +770 -0
- package/src/errors.ts +23 -0
- package/src/git.ts +178 -0
- package/src/index.ts +100 -0
- package/src/path-utils.ts +255 -0
- package/src/rewind-host.ts +584 -0
- package/src/snapshot.ts +229 -0
- package/src/store.ts +383 -0
- package/src/types.ts +195 -0
- package/src/validate.ts +245 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Modal,
|
|
6
|
+
Tooltip,
|
|
7
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
8
|
+
|
|
9
|
+
interface ConversationNodeLike {
|
|
10
|
+
readonly kind: string
|
|
11
|
+
readonly seq: number
|
|
12
|
+
readonly content?: readonly { readonly type: string; readonly text?: string }[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ConversationChatNodeLike {
|
|
16
|
+
readonly key: string
|
|
17
|
+
readonly kind: string
|
|
18
|
+
readonly data: ConversationNodeLike
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ConversationSnapshotLike {
|
|
22
|
+
readonly nodes: readonly ConversationNodeLike[]
|
|
23
|
+
readonly chat?: {
|
|
24
|
+
readonly nodes: {
|
|
25
|
+
values(): readonly ConversationChatNodeLike[]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type RewindNodeLike = ConversationNodeLike | ConversationChatNodeLike
|
|
31
|
+
|
|
32
|
+
interface RewindMatch {
|
|
33
|
+
readonly messageSeq: number
|
|
34
|
+
readonly promptText: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RewindMessageActionProps {
|
|
38
|
+
readonly matched: RewindMatch
|
|
39
|
+
readonly sessionId: string
|
|
40
|
+
readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface RewindPortalBridgeProps {
|
|
44
|
+
readonly sessionId: string
|
|
45
|
+
readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
|
|
46
|
+
readonly useSession: <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RewindPortalTarget {
|
|
50
|
+
readonly container: HTMLElement
|
|
51
|
+
readonly matched: RewindMatch
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SlotsLike {
|
|
55
|
+
inject(name: string, install: () => unknown): void
|
|
56
|
+
register(
|
|
57
|
+
entry: {
|
|
58
|
+
readonly name: string
|
|
59
|
+
readonly id: string
|
|
60
|
+
readonly order: number
|
|
61
|
+
readonly inject: () => {
|
|
62
|
+
readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
component: (props: RewindPortalBridgeProps) => ReactNode,
|
|
66
|
+
): () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ClientContextLike {
|
|
70
|
+
readonly slots: SlotsLike
|
|
71
|
+
readonly sessions: {
|
|
72
|
+
open(sessionId: string): void
|
|
73
|
+
scope(sessionId: string): unknown | undefined
|
|
74
|
+
}
|
|
75
|
+
readonly conversation: {
|
|
76
|
+
readonly input: {
|
|
77
|
+
for(scope: unknown): { setDraft(text: string): void }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
effect(setup: () => (() => void), label?: string): unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type RewindMode = 'both' | 'code'
|
|
84
|
+
type ChangeKind = 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed'
|
|
85
|
+
|
|
86
|
+
interface ReadyPreview {
|
|
87
|
+
readonly status: 'ready'
|
|
88
|
+
readonly sessionId: string
|
|
89
|
+
readonly messageSeq: number
|
|
90
|
+
readonly turn: number
|
|
91
|
+
readonly checkpointId: string
|
|
92
|
+
readonly turnStartSeq: number
|
|
93
|
+
readonly totalChanges: number
|
|
94
|
+
readonly changes: readonly { readonly path: string; readonly kind: ChangeKind }[]
|
|
95
|
+
readonly offset: number
|
|
96
|
+
readonly truncated: boolean
|
|
97
|
+
readonly headChanged: boolean
|
|
98
|
+
readonly operationChanged: boolean
|
|
99
|
+
readonly checkpointHead?: string
|
|
100
|
+
readonly checkpointBranch?: string
|
|
101
|
+
readonly checkpointOperation?: string
|
|
102
|
+
readonly currentHead?: string
|
|
103
|
+
readonly currentBranch?: string
|
|
104
|
+
readonly currentOperation?: string
|
|
105
|
+
readonly activeSessionIds: readonly string[]
|
|
106
|
+
readonly restoreBlocked: boolean
|
|
107
|
+
readonly planId?: string
|
|
108
|
+
readonly confirmation?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type Preview = ReadyPreview
|
|
112
|
+
| { readonly status: 'pending' }
|
|
113
|
+
| { readonly status: 'missing' }
|
|
114
|
+
| { readonly status: 'failed'; readonly error: string }
|
|
115
|
+
|
|
116
|
+
const PATH = '/turn-rewind'
|
|
117
|
+
const STYLE_ID = '@anionex/dsh-turn-rewind'
|
|
118
|
+
const styles = `
|
|
119
|
+
.dcl-rewind-tail{display:inline-flex;align-items:center;align-self:center;order:0;height:24px;margin-left:2px}
|
|
120
|
+
.dcl-rewind-trigger{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}
|
|
121
|
+
.dcl-rewind-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
122
|
+
.dcl-rewind-dialog{box-sizing:border-box;width:min(560px,100%);max-height:calc(100dvh - 48px)}
|
|
123
|
+
.dcl-rewind-content{min-width:0;min-height:0;overflow-y:auto;overscroll-behavior:contain}
|
|
124
|
+
.dcl-rewind-body{display:flex;flex-direction:column;gap:14px;width:100%;min-width:0;max-width:100%;box-sizing:border-box}
|
|
125
|
+
.dcl-rewind-options{display:flex;flex-direction:column;gap:8px;min-width:0;max-width:100%}
|
|
126
|
+
.dcl-rewind-option{display:flex;align-items:flex-start;gap:10px;width:100%;min-width:0;box-sizing:border-box;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);cursor:pointer}
|
|
127
|
+
.dcl-rewind-option[data-selected="true"]{border-color:var(--dsw-alias-state-business-primary)}
|
|
128
|
+
.dcl-rewind-option[data-disabled="true"]{cursor:not-allowed;opacity:.52}
|
|
129
|
+
.dcl-rewind-option input{flex:none;margin:2px 0 0}
|
|
130
|
+
.dcl-rewind-option-content{display:block;flex:1;min-width:0}
|
|
131
|
+
.dcl-rewind-option strong{display:block;color:var(--dsw-alias-label-primary);font-size:14px}
|
|
132
|
+
.dcl-rewind-option-description{display:block;margin-top:3px;overflow-wrap:anywhere;word-break:break-word;color:var(--dsw-alias-label-tertiary);font-size:12px}
|
|
133
|
+
.dcl-rewind-summary{display:flex;flex-wrap:wrap;column-gap:16px;row-gap:4px;min-width:0;color:var(--dsw-alias-label-secondary);font-size:13px}
|
|
134
|
+
.dcl-rewind-files{min-width:0;max-width:100%;box-sizing:border-box;max-height:220px;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px}
|
|
135
|
+
.dcl-rewind-file{display:flex;justify-content:space-between;gap:16px;min-width:0;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}
|
|
136
|
+
.dcl-rewind-file:last-child{border-bottom:0}.dcl-rewind-file code{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary)}
|
|
137
|
+
.dcl-rewind-kind{flex:none;color:var(--dsw-alias-label-tertiary)}
|
|
138
|
+
.dcl-rewind-file-actions{display:flex;justify-content:flex-start}
|
|
139
|
+
.dcl-rewind-status{margin:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
|
|
140
|
+
.dcl-rewind-warning,.dcl-rewind-error{box-sizing:border-box;max-width:100%;margin:0;padding:10px 12px;overflow-wrap:anywhere;word-break:break-word;border-radius:10px;font-size:12px;line-height:18px}
|
|
141
|
+
.dcl-rewind-warning{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary)}
|
|
142
|
+
.dcl-rewind-error{border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 30%,transparent);color:var(--dsw-alias-state-error-primary)}
|
|
143
|
+
.dcl-rewind-backup{box-sizing:border-box;margin:0;padding:10px 12px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
144
|
+
.dcl-rewind-retry{align-self:flex-start}
|
|
145
|
+
`
|
|
146
|
+
|
|
147
|
+
/** Return the rewind anchor and editable text owned by one direct user message. */
|
|
148
|
+
export function selectRewindMessage(node: ConversationNodeLike): RewindMatch | null {
|
|
149
|
+
if (node.kind !== 'user' || !Number.isSafeInteger(node.seq) || node.seq < 0) return null
|
|
150
|
+
const promptText = (node.content ?? [])
|
|
151
|
+
.filter((block): block is { readonly type: string; readonly text: string } => block.type === 'text' && typeof block.text === 'string')
|
|
152
|
+
.map(block => block.text)
|
|
153
|
+
.join('\n')
|
|
154
|
+
return { messageSeq: node.seq, promptText }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Browser plugin entry: bridge every direct user-message action row to the rewind UI. */
|
|
158
|
+
export const inject = ['slots', 'sessions', 'conversation']
|
|
159
|
+
export function apply(ctx: ClientContextLike): void {
|
|
160
|
+
ctx.effect(() => {
|
|
161
|
+
if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return () => {}
|
|
162
|
+
const tag = document.createElement('style')
|
|
163
|
+
tag.dataset.plugin = '@anionex/dsh-turn-rewind'
|
|
164
|
+
tag.dataset.pluginCss = STYLE_ID
|
|
165
|
+
tag.textContent = styles
|
|
166
|
+
document.head.appendChild(tag)
|
|
167
|
+
return () => { tag.remove() }
|
|
168
|
+
}, 'turn-rewind: styles')
|
|
169
|
+
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
170
|
+
name: 'conversation.session.header.actions',
|
|
171
|
+
id: 'turn-rewind-portals',
|
|
172
|
+
order: 100,
|
|
173
|
+
inject: () => ({
|
|
174
|
+
openRestoredSession: async (sessionId: string, promptText: string) => {
|
|
175
|
+
await openSessionWithDraft(ctx, sessionId, promptText)
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
}, RewindMessagePortals))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
|
|
182
|
+
export function RewindMessagePortals({ sessionId, openRestoredSession, useSession }: RewindPortalBridgeProps): ReactNode {
|
|
183
|
+
const nodes = useSession<readonly RewindNodeLike[]>(snapshot => snapshot.chat?.nodes.values() ?? snapshot.nodes)
|
|
184
|
+
const [targets, setTargets] = useState<readonly RewindPortalTarget[]>([])
|
|
185
|
+
|
|
186
|
+
useLayoutEffect(() => {
|
|
187
|
+
let active = true
|
|
188
|
+
let queued = false
|
|
189
|
+
const refresh = (): void => {
|
|
190
|
+
if (!active) return
|
|
191
|
+
const next = collectPortalTargets(nodes)
|
|
192
|
+
setTargets(current => samePortalTargets(current, next) ? current : next)
|
|
193
|
+
}
|
|
194
|
+
const queueRefresh = (): void => {
|
|
195
|
+
if (queued || !active) return
|
|
196
|
+
queued = true
|
|
197
|
+
queueMicrotask(() => {
|
|
198
|
+
queued = false
|
|
199
|
+
refresh()
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
refresh()
|
|
203
|
+
const observer = new MutationObserver(queueRefresh)
|
|
204
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
205
|
+
return () => {
|
|
206
|
+
active = false
|
|
207
|
+
observer.disconnect()
|
|
208
|
+
}
|
|
209
|
+
}, [nodes])
|
|
210
|
+
|
|
211
|
+
return targets.map(target => createPortal(
|
|
212
|
+
<RewindMessageAction matched={target.matched} sessionId={sessionId} openRestoredSession={openRestoredSession} />,
|
|
213
|
+
target.container,
|
|
214
|
+
`${sessionId}:${String(target.matched.messageSeq)}`,
|
|
215
|
+
))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** User-message action and its review-first file/conversation restore dialog. */
|
|
219
|
+
export function RewindMessageAction({ matched, sessionId, openRestoredSession }: RewindMessageActionProps): ReactNode {
|
|
220
|
+
const [open, setOpen] = useState(false)
|
|
221
|
+
const [loading, setLoading] = useState(false)
|
|
222
|
+
const [preview, setPreview] = useState<Preview | null>(null)
|
|
223
|
+
const [mode, setMode] = useState<RewindMode>('both')
|
|
224
|
+
const [applying, setApplying] = useState(false)
|
|
225
|
+
const [loadingDetails, setLoadingDetails] = useState(false)
|
|
226
|
+
const [stale, setStale] = useState(false)
|
|
227
|
+
const [error, setError] = useState<string | null>(null)
|
|
228
|
+
const [completed, setCompleted] = useState<string | null>(null)
|
|
229
|
+
const loadAbort = useRef<AbortController | null>(null)
|
|
230
|
+
const applyPending = useRef(false)
|
|
231
|
+
|
|
232
|
+
useEffect(() => () => {
|
|
233
|
+
loadAbort.current?.abort()
|
|
234
|
+
loadAbort.current = null
|
|
235
|
+
}, [])
|
|
236
|
+
|
|
237
|
+
const load = useCallback(async () => {
|
|
238
|
+
loadAbort.current?.abort()
|
|
239
|
+
const controller = new AbortController()
|
|
240
|
+
loadAbort.current = controller
|
|
241
|
+
setLoading(true)
|
|
242
|
+
setStale(false)
|
|
243
|
+
setError(null)
|
|
244
|
+
setCompleted(null)
|
|
245
|
+
try {
|
|
246
|
+
const response = await fetch(`${PATH}?sessionId=${encodeURIComponent(sessionId)}&messageSeq=${String(matched.messageSeq)}`, {
|
|
247
|
+
method: 'GET', headers: { accept: 'application/json' }, cache: 'no-store', signal: controller.signal,
|
|
248
|
+
})
|
|
249
|
+
const value = await responseJson(response)
|
|
250
|
+
if (loadAbort.current === controller) setPreview(decodePreview(value))
|
|
251
|
+
} catch (caught) {
|
|
252
|
+
if (!controller.signal.aborted) setError(messageOf(caught))
|
|
253
|
+
} finally {
|
|
254
|
+
if (loadAbort.current === controller) {
|
|
255
|
+
loadAbort.current = null
|
|
256
|
+
setLoading(false)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}, [matched.messageSeq, sessionId])
|
|
260
|
+
|
|
261
|
+
const show = (): void => {
|
|
262
|
+
setOpen(true)
|
|
263
|
+
setPreview(null)
|
|
264
|
+
setMode('both')
|
|
265
|
+
setStale(false)
|
|
266
|
+
void load()
|
|
267
|
+
}
|
|
268
|
+
const close = (): void => {
|
|
269
|
+
if (applying) return
|
|
270
|
+
loadAbort.current?.abort()
|
|
271
|
+
loadAbort.current = null
|
|
272
|
+
setLoading(false)
|
|
273
|
+
setOpen(false)
|
|
274
|
+
}
|
|
275
|
+
const chooseMode = (next: RewindMode): void => {
|
|
276
|
+
if (applying) return
|
|
277
|
+
setMode(next)
|
|
278
|
+
setError(null)
|
|
279
|
+
setCompleted(null)
|
|
280
|
+
}
|
|
281
|
+
const ready = preview?.status === 'ready' ? preview : null
|
|
282
|
+
const hasFileChanges = ready !== null && ready.totalChanges > 0
|
|
283
|
+
const driftBlocked = hasFileChanges && ready?.operationChanged === true
|
|
284
|
+
const sharedBlocked = (ready?.activeSessionIds.length ?? 0) > 0
|
|
285
|
+
const planMissing = hasFileChanges && ready !== null && !sharedBlocked && !driftBlocked
|
|
286
|
+
&& (ready.planId === undefined || ready.confirmation === undefined)
|
|
287
|
+
const canApply = ready !== null
|
|
288
|
+
&& !loading
|
|
289
|
+
&& !applying
|
|
290
|
+
&& !loadingDetails
|
|
291
|
+
&& completed === null
|
|
292
|
+
&& hasFileChanges
|
|
293
|
+
&& !driftBlocked
|
|
294
|
+
&& !sharedBlocked
|
|
295
|
+
&& !planMissing
|
|
296
|
+
&& !stale
|
|
297
|
+
|
|
298
|
+
const loadAllChanges = async (): Promise<void> => {
|
|
299
|
+
if (ready === null || loadingDetails || !ready.truncated) return
|
|
300
|
+
setLoadingDetails(true)
|
|
301
|
+
setError(null)
|
|
302
|
+
try {
|
|
303
|
+
const collected = [...ready.changes]
|
|
304
|
+
let offset = collected.length
|
|
305
|
+
while (offset < ready.totalChanges) {
|
|
306
|
+
const response = await fetch(`${PATH}?sessionId=${encodeURIComponent(sessionId)}&messageSeq=${String(matched.messageSeq)}&details=1&offset=${String(offset)}&limit=200`, {
|
|
307
|
+
method: 'GET', headers: { accept: 'application/json' }, cache: 'no-store',
|
|
308
|
+
})
|
|
309
|
+
const page = decodePreview(await responseJson(response))
|
|
310
|
+
if (page.status !== 'ready'
|
|
311
|
+
|| page.checkpointId !== ready.checkpointId
|
|
312
|
+
|| page.totalChanges !== ready.totalChanges
|
|
313
|
+
|| page.offset !== offset) {
|
|
314
|
+
throw new RewindRequestError('PLAN_STALE', '项目文件在展开列表时发生了变化。')
|
|
315
|
+
}
|
|
316
|
+
collected.push(...page.changes)
|
|
317
|
+
offset += page.changes.length
|
|
318
|
+
if (page.changes.length === 0) break
|
|
319
|
+
}
|
|
320
|
+
if (offset !== ready.totalChanges) throw new RewindRequestError('PLAN_STALE', '无法读取完整的文件列表。')
|
|
321
|
+
setPreview({ ...ready, changes: collected, truncated: false })
|
|
322
|
+
} catch (caught) {
|
|
323
|
+
if (caught instanceof RewindRequestError && caught.code === 'PLAN_STALE') setStale(true)
|
|
324
|
+
setError(friendlyError(caught))
|
|
325
|
+
} finally {
|
|
326
|
+
setLoadingDetails(false)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const applyRestore = async (): Promise<void> => {
|
|
331
|
+
if (ready === null || !canApply || applyPending.current) return
|
|
332
|
+
const body: Record<string, unknown> = {
|
|
333
|
+
mode,
|
|
334
|
+
sessionId,
|
|
335
|
+
messageSeq: ready.messageSeq,
|
|
336
|
+
checkpointId: ready.checkpointId,
|
|
337
|
+
}
|
|
338
|
+
if (ready.planId === undefined || ready.confirmation === undefined) return
|
|
339
|
+
body.planId = ready.planId
|
|
340
|
+
body.confirmation = ready.confirmation
|
|
341
|
+
applyPending.current = true
|
|
342
|
+
setApplying(true)
|
|
343
|
+
setError(null)
|
|
344
|
+
try {
|
|
345
|
+
const response = await fetch(PATH, {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
348
|
+
body: JSON.stringify(body),
|
|
349
|
+
})
|
|
350
|
+
const result = recordOf(await responseJson(response))
|
|
351
|
+
const resultMode = requiredString(result.mode, 'mode')
|
|
352
|
+
if (resultMode !== mode) throw new Error(`服务器返回了不匹配的回退模式:${resultMode}`)
|
|
353
|
+
if (mode === 'code') {
|
|
354
|
+
requiredString(result.rescuePointId, 'rescuePointId')
|
|
355
|
+
setCompleted('项目文件已恢复;当前对话保持不变。恢复前的文件已自动备份。')
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
const childSessionId = requiredString(result.sessionId, 'sessionId')
|
|
359
|
+
requiredString(result.rescuePointId, 'rescuePointId')
|
|
360
|
+
setCompleted('项目文件已恢复,并已创建新对话。恢复前的文件已自动备份。')
|
|
361
|
+
try {
|
|
362
|
+
await openRestoredSession(childSessionId, matched.promptText)
|
|
363
|
+
setOpen(false)
|
|
364
|
+
} catch (navigationError) {
|
|
365
|
+
setError(`文件已经恢复,新对话也已创建,但没能自动打开:${messageOf(navigationError)}`)
|
|
366
|
+
}
|
|
367
|
+
} catch (caught) {
|
|
368
|
+
if (caught instanceof RewindRequestError && (caught.code === 'PLAN_STALE' || caught.code === 'WORKSPACE_IN_USE')) {
|
|
369
|
+
setStale(true)
|
|
370
|
+
}
|
|
371
|
+
setError(friendlyError(caught))
|
|
372
|
+
} finally {
|
|
373
|
+
applyPending.current = false
|
|
374
|
+
setApplying(false)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const actionLabel = mode === 'both' ? '恢复并从这里继续' : '恢复文件'
|
|
379
|
+
const radioName = `dcl-rewind-${sessionId}-${String(matched.messageSeq)}`
|
|
380
|
+
const branchChanged = ready !== null && ready.checkpointBranch !== ready.currentBranch
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<div className="dcl-rewind-tail">
|
|
384
|
+
<Tooltip label="恢复到发送这条消息之前" side="bottom">
|
|
385
|
+
<button type="button" className="dcl-rewind-trigger" onClick={show} aria-label="恢复到发送这条消息之前">
|
|
386
|
+
<RewindIcon size={16} />
|
|
387
|
+
</button>
|
|
388
|
+
</Tooltip>
|
|
389
|
+
<Modal
|
|
390
|
+
open={open}
|
|
391
|
+
onClose={close}
|
|
392
|
+
title="恢复到发送这条消息之前"
|
|
393
|
+
closeLabel="关闭"
|
|
394
|
+
description="查看恢复的文件,选择适合你的回退方式。当前会话不受影响。"
|
|
395
|
+
className="dcl-rewind-dialog"
|
|
396
|
+
contentClassName="dcl-rewind-content"
|
|
397
|
+
footer={(
|
|
398
|
+
<>
|
|
399
|
+
<Button variant="outline" onClick={close} disabled={applying}>取消</Button>
|
|
400
|
+
<Button variant="primary" onClick={() => { void applyRestore() }} disabled={!canApply}>
|
|
401
|
+
{applying ? '正在恢复…' : completed === null ? actionLabel : '已完成'}
|
|
402
|
+
</Button>
|
|
403
|
+
</>
|
|
404
|
+
)}
|
|
405
|
+
>
|
|
406
|
+
<div className="dcl-rewind-body">
|
|
407
|
+
{loading && <p className="dcl-rewind-status">正在检查可以恢复的项目文件…</p>}
|
|
408
|
+
{preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
|
|
409
|
+
{preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能,或记录已超过保留期限。</p>}
|
|
410
|
+
{preview?.status === 'failed' && <p className="dcl-rewind-error">没能保存这条消息发送前的文件:{preview.error}</p>}
|
|
411
|
+
{ready !== null && (
|
|
412
|
+
<>
|
|
413
|
+
<div className="dcl-rewind-options">
|
|
414
|
+
<label className="dcl-rewind-option" data-selected={mode === 'both'} data-disabled={applying}>
|
|
415
|
+
<input type="radio" name={radioName} checked={mode === 'both'} disabled={applying} onChange={() => { chooseMode('both') }} />
|
|
416
|
+
<span className="dcl-rewind-option-content"><strong>恢复文件并从这里继续</strong><span className="dcl-rewind-option-description">创建一个从这里开始的新会话(当前对话会保留)</span></span>
|
|
417
|
+
</label>
|
|
418
|
+
<label className="dcl-rewind-option" data-selected={mode === 'code'} data-disabled={applying}>
|
|
419
|
+
<input type="radio" name={radioName} checked={mode === 'code'} disabled={applying} onChange={() => { chooseMode('code') }} />
|
|
420
|
+
<span className="dcl-rewind-option-content"><strong>只恢复文件</strong><span className="dcl-rewind-option-description">恢复这条消息发送前的文件,当前对话保持不变。</span></span>
|
|
421
|
+
</label>
|
|
422
|
+
</div>
|
|
423
|
+
<div className="dcl-rewind-summary">
|
|
424
|
+
<strong>将恢复 {String(ready.totalChanges)} 个文件</strong>
|
|
425
|
+
<span>{mode === 'both' ? '恢复后在新对话里继续' : '当前对话保持不变'}</span>
|
|
426
|
+
</div>
|
|
427
|
+
{sharedBlocked && (
|
|
428
|
+
<p className="dcl-rewind-error">这个项目目录还有别的对话正在运行。恢复文件会影响到它们,因此本次操作已被阻止。请等那些对话结束或停止后,再重新检查。</p>
|
|
429
|
+
)}
|
|
430
|
+
{ready.headChanged && !ready.operationChanged && (
|
|
431
|
+
<p className="dcl-rewind-warning">{branchChanged
|
|
432
|
+
? '当前所在的 Git 分支和发送这条消息时不同。恢复不会切换分支,只会把当时的文件内容恢复到当前分支。'
|
|
433
|
+
: '这条消息之后有了新的 Git 提交。恢复只会改文件,不会撤销提交;完成后这些文件会显示为未提交修改。'}</p>
|
|
434
|
+
)}
|
|
435
|
+
{driftBlocked && <p className="dcl-rewind-warning">Git 正在进行合并、变基或类似操作。请先完成或取消这次 Git 操作,再重新检查。</p>}
|
|
436
|
+
{planMissing && <p className="dcl-rewind-error">恢复信息已经失效,请重新检查。</p>}
|
|
437
|
+
{stale && <p className="dcl-rewind-error">项目文件在检查后又发生了变化。为避免覆盖新修改,这次恢复已失效,请重新检查。</p>}
|
|
438
|
+
{ready.totalChanges === 0 && <p className="dcl-rewind-status">项目文件已经是这条消息发送前的状态,无需恢复。想重新开始时,可以使用“分支新对话”。</p>}
|
|
439
|
+
{ready.changes.length > 0 && (
|
|
440
|
+
<div className="dcl-rewind-files">
|
|
441
|
+
{ready.changes.map(change => <div className="dcl-rewind-file" key={change.path}><code>{change.path}</code><span className="dcl-rewind-kind">{fileRecoveryLabel(change.kind)}</span></div>)}
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
{ready.truncated && (
|
|
445
|
+
<div className="dcl-rewind-file-actions"><Button variant="outline" size="sm" onClick={() => { void loadAllChanges() }} disabled={loadingDetails}>{loadingDetails ? '正在读取全部文件…' : `查看全部 ${String(ready.totalChanges)} 个文件`}</Button></div>
|
|
446
|
+
)}
|
|
447
|
+
</>
|
|
448
|
+
)}
|
|
449
|
+
{completed !== null && <p className="dcl-rewind-status">{completed}</p>}
|
|
450
|
+
{error !== null && <p className="dcl-rewind-error">{error}</p>}
|
|
451
|
+
{error !== null && <p className="dcl-rewind-backup">恢复前会自动备份当前文件;若恢复失败会自动还原,项目不会停留在只恢复了一部分的状态。</p>}
|
|
452
|
+
{!loading && (preview?.status !== 'ready' || stale || planMissing || sharedBlocked || driftBlocked) && <Button className="dcl-rewind-retry" variant="outline" size="sm" onClick={() => { void load() }}>重新检查</Button>}
|
|
453
|
+
</div>
|
|
454
|
+
</Modal>
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function decodePreview(value: unknown): Preview {
|
|
460
|
+
const record = recordOf(value)
|
|
461
|
+
const status = requiredString(record.status, 'status')
|
|
462
|
+
if (status === 'pending' || status === 'missing') return { status }
|
|
463
|
+
if (status === 'failed') return { status, error: requiredString(record.error, 'error') }
|
|
464
|
+
if (status !== 'ready') throw new Error(`未知回退状态:${status}`)
|
|
465
|
+
const changesValue = record.changes
|
|
466
|
+
if (!Array.isArray(changesValue)) throw new Error('回退预览缺少 changes')
|
|
467
|
+
const changes = changesValue.map((entry) => {
|
|
468
|
+
const change = recordOf(entry)
|
|
469
|
+
return { path: requiredString(change.path, 'path'), kind: requiredString(change.kind, 'kind') as ChangeKind }
|
|
470
|
+
})
|
|
471
|
+
const activeSessionIdsValue = record.activeSessionIds
|
|
472
|
+
if (!Array.isArray(activeSessionIdsValue) || !activeSessionIdsValue.every(value => typeof value === 'string')) {
|
|
473
|
+
throw new Error('回退预览缺少 activeSessionIds')
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
status,
|
|
477
|
+
sessionId: requiredString(record.sessionId, 'sessionId'),
|
|
478
|
+
messageSeq: requiredInteger(record.messageSeq, 'messageSeq'),
|
|
479
|
+
turn: requiredInteger(record.turn, 'turn'),
|
|
480
|
+
checkpointId: requiredString(record.checkpointId, 'checkpointId'),
|
|
481
|
+
turnStartSeq: requiredInteger(record.turnStartSeq, 'turnStartSeq'),
|
|
482
|
+
totalChanges: requiredInteger(record.totalChanges, 'totalChanges'),
|
|
483
|
+
changes,
|
|
484
|
+
offset: requiredInteger(record.offset, 'offset'),
|
|
485
|
+
truncated: requiredBoolean(record.truncated, 'truncated'),
|
|
486
|
+
headChanged: requiredBoolean(record.headChanged, 'headChanged'),
|
|
487
|
+
operationChanged: requiredBoolean(record.operationChanged, 'operationChanged'),
|
|
488
|
+
...optionalRecordString(record, 'checkpointHead'),
|
|
489
|
+
...optionalRecordString(record, 'checkpointBranch'),
|
|
490
|
+
...optionalRecordString(record, 'checkpointOperation'),
|
|
491
|
+
...optionalRecordString(record, 'currentHead'),
|
|
492
|
+
...optionalRecordString(record, 'currentBranch'),
|
|
493
|
+
...optionalRecordString(record, 'currentOperation'),
|
|
494
|
+
activeSessionIds: activeSessionIdsValue as string[],
|
|
495
|
+
restoreBlocked: requiredBoolean(record.restoreBlocked, 'restoreBlocked'),
|
|
496
|
+
...(typeof record.planId === 'string' ? { planId: record.planId } : {}),
|
|
497
|
+
...(typeof record.confirmation === 'string' ? { confirmation: record.confirmation } : {}),
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Resolve one conversation node to its DOM row key and rewind match. */
|
|
502
|
+
export function selectRewindMessageTarget(value: RewindNodeLike): { readonly matched: RewindMatch; readonly rowKey: string } | null {
|
|
503
|
+
const node = 'key' in value && 'data' in value ? value.data : value
|
|
504
|
+
const matched = selectRewindMessage(node)
|
|
505
|
+
if (matched === null) return null
|
|
506
|
+
return {
|
|
507
|
+
matched,
|
|
508
|
+
rowKey: 'key' in value && 'data' in value ? value.key : `node:${String(node.seq)}`,
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function collectPortalTargets(nodes: readonly RewindNodeLike[]): readonly RewindPortalTarget[] {
|
|
513
|
+
const rows = new Map<string, HTMLElement>()
|
|
514
|
+
for (const element of Array.from(document.querySelectorAll<HTMLElement>(
|
|
515
|
+
'[data-chat-flow-kind="user"][data-chat-anchor-key]',
|
|
516
|
+
))) {
|
|
517
|
+
const key = element.dataset.chatAnchorKey
|
|
518
|
+
if (key !== undefined) rows.set(key, element)
|
|
519
|
+
}
|
|
520
|
+
const targets: RewindPortalTarget[] = []
|
|
521
|
+
for (const value of nodes) {
|
|
522
|
+
const target = selectRewindMessageTarget(value)
|
|
523
|
+
if (target === null) continue
|
|
524
|
+
const row = rows.get(target.rowKey)
|
|
525
|
+
const messageRoot = row?.querySelector<HTMLElement>('[data-time-hover-root="true"]')
|
|
526
|
+
const actions = messageRoot?.lastElementChild
|
|
527
|
+
if (!(actions instanceof HTMLElement) || actions.querySelector(':scope > button') === null) continue
|
|
528
|
+
targets.push({ container: actions, matched: target.matched })
|
|
529
|
+
}
|
|
530
|
+
return targets
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function samePortalTargets(
|
|
534
|
+
left: readonly RewindPortalTarget[],
|
|
535
|
+
right: readonly RewindPortalTarget[],
|
|
536
|
+
): boolean {
|
|
537
|
+
return left.length === right.length && left.every((target, index) => {
|
|
538
|
+
const other = right[index]
|
|
539
|
+
return other !== undefined
|
|
540
|
+
&& target.container === other.container
|
|
541
|
+
&& target.matched.messageSeq === other.matched.messageSeq
|
|
542
|
+
&& target.matched.promptText === other.matched.promptText
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function openSessionWithDraft(ctx: ClientContextLike, sessionId: string, promptText: string): Promise<void> {
|
|
547
|
+
let lastError: unknown = new Error('新对话还没有准备好')
|
|
548
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
549
|
+
try {
|
|
550
|
+
ctx.sessions.open(sessionId)
|
|
551
|
+
const scope = ctx.sessions.scope(sessionId)
|
|
552
|
+
if (scope !== undefined) {
|
|
553
|
+
ctx.conversation.input.for(scope).setDraft(promptText)
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
lastError = new Error('新对话还没有准备好')
|
|
557
|
+
} catch (error) {
|
|
558
|
+
lastError = error
|
|
559
|
+
}
|
|
560
|
+
await new Promise<void>(resolve => { setTimeout(resolve, 50) })
|
|
561
|
+
}
|
|
562
|
+
throw lastError
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function responseJson(response: Response): Promise<unknown> {
|
|
566
|
+
const value = await response.json() as unknown
|
|
567
|
+
if (!response.ok) {
|
|
568
|
+
const record = recordOf(value)
|
|
569
|
+
throw new RewindRequestError(
|
|
570
|
+
typeof record.code === 'string' ? record.code : 'REWIND_FAILED',
|
|
571
|
+
typeof record.error === 'string' ? record.error : `请求失败:${String(response.status)}`,
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
return value
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
class RewindRequestError extends Error {
|
|
578
|
+
constructor(readonly code: string, message: string) {
|
|
579
|
+
super(message)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function recordOf(value: unknown): Record<string, unknown> {
|
|
584
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('服务器返回了无效对象')
|
|
585
|
+
return value as Record<string, unknown>
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function requiredString(value: unknown, name: string): string {
|
|
589
|
+
if (typeof value !== 'string' || value === '') throw new Error(`${name} 无效`)
|
|
590
|
+
return value
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function requiredInteger(value: unknown, name: string): number {
|
|
594
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} 无效`)
|
|
595
|
+
return value as number
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function requiredBoolean(value: unknown, name: string): boolean {
|
|
599
|
+
if (typeof value !== 'boolean') throw new Error(`${name} 无效`)
|
|
600
|
+
return value
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function optionalRecordString(record: Record<string, unknown>, name: string): Record<string, string> {
|
|
604
|
+
const value = record[name]
|
|
605
|
+
if (value === undefined) return {}
|
|
606
|
+
return { [name]: requiredString(value, name) }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Describe the user-visible result of restoring one changed file. */
|
|
610
|
+
export function fileRecoveryLabel(kind: ChangeKind): string {
|
|
611
|
+
switch (kind) {
|
|
612
|
+
case 'added': return '移除后来新增的文件'
|
|
613
|
+
case 'deleted': return '找回文件'
|
|
614
|
+
case 'modified': return '恢复之前的版本'
|
|
615
|
+
case 'mode-changed': return '恢复文件权限'
|
|
616
|
+
case 'type-changed': return '恢复之前的文件类型'
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function RewindIcon({ size }: { readonly size: number }): ReactNode {
|
|
621
|
+
return (
|
|
622
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
623
|
+
<path d="M6.35 3.25 2.75 7l3.6 3.75M3.1 7h5.15a4.25 4.25 0 0 1 4.25 4.25v1.25" stroke="currentColor" strokeWidth="1.45" strokeLinecap="round" strokeLinejoin="round" />
|
|
624
|
+
</svg>
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function friendlyError(error: unknown): string {
|
|
629
|
+
if (!(error instanceof RewindRequestError)) return messageOf(error)
|
|
630
|
+
switch (error.code) {
|
|
631
|
+
case 'PLAN_STALE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
|
|
632
|
+
case 'PLAN_STALE_REPOSITORY': return 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。'
|
|
633
|
+
case 'WORKSPACE_IN_USE': return '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。'
|
|
634
|
+
case 'WORKSPACE_LOCKED': return '另一个恢复操作正在处理这个项目目录。请等待它完成后重新检查。'
|
|
635
|
+
case 'HEAD_CHANGED': return '项目的提交或分支已发生变化。为避免覆盖新改动,请重新检查后再恢复。'
|
|
636
|
+
case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
|
|
637
|
+
case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
|
|
638
|
+
case 'RESTORE_POINT_NOT_FOUND': return '没有找到对应的文件状态,可能已被清理。'
|
|
639
|
+
case 'NO_CHANGES': return '项目文件已经是这条消息发送前的状态,无需恢复。想重新开始时,可以使用“分支新对话”。'
|
|
640
|
+
case 'RESTORE_FAILED_ROLLED_BACK': return '恢复未能完成,项目文件已自动还原到操作前的状态。'
|
|
641
|
+
case 'CONVERSATION_REWIND_FAILED': return '文件已恢复,但无法创建新对话;项目文件已自动还原。'
|
|
642
|
+
default: return error.message
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function messageOf(error: unknown): string {
|
|
647
|
+
return error instanceof Error ? error.message : String(error)
|
|
648
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
declare module '@deepseek-ai/dsh-client-ui-primitives' {
|
|
2
|
+
import type { ButtonHTMLAttributes, ComponentType, ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
export const Tooltip: ComponentType<{ label: string; side?: string; children: ReactNode }>
|
|
5
|
+
export const Modal: ComponentType<{
|
|
6
|
+
open: boolean
|
|
7
|
+
onClose: () => void
|
|
8
|
+
title: string
|
|
9
|
+
closeLabel?: string
|
|
10
|
+
description?: string
|
|
11
|
+
children?: ReactNode
|
|
12
|
+
footer?: ReactNode
|
|
13
|
+
className?: string
|
|
14
|
+
contentClassName?: string
|
|
15
|
+
}>
|
|
16
|
+
export const Button: ComponentType<{
|
|
17
|
+
variant?: 'primary' | 'ghost' | 'outline' | 'toolbar'
|
|
18
|
+
size?: 'md' | 'sm'
|
|
19
|
+
icon?: ReactNode
|
|
20
|
+
} & ButtonHTMLAttributes<HTMLButtonElement>>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
declare module 'react-dom' {
|
|
24
|
+
import type { ReactNode } from 'react'
|
|
25
|
+
|
|
26
|
+
export function createPortal(children: ReactNode, container: Element | DocumentFragment, key?: string | null): ReactNode
|
|
27
|
+
}
|