dsh-conversation-recycle-bin 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/AUDIT-2026-10-17-desktop-web.md +333 -0
- package/CHANGELOG.md +57 -0
- package/INSTALL.md +2 -1
- package/README.md +16 -12
- package/client.js +197 -26
- package/config.schema.json +1 -1
- package/lib/core/cascade.js +53 -19
- package/lib/core/config.js +4 -1
- package/lib/core/errors.js +5 -2
- package/lib/core/file-lock.js +151 -0
- package/lib/core/memory/generic-json.js +2 -2
- package/lib/core/memory/json-index-adapter.js +182 -87
- package/lib/core/memory/lingshu.js +10 -4
- package/lib/core/memory/none.js +4 -2
- package/lib/core/memory/registry.js +9 -6
- package/lib/core/policy.js +4 -4
- package/lib/core/redact.js +66 -0
- package/lib/host/api.js +252 -55
- package/lib/host/catalog.js +101 -36
- package/lib/host/gateway.js +19 -5
- package/lib/host/paths.js +13 -2
- package/lib/version.js +1 -1
- package/package.json +11 -3
- package/locale/en.json +0 -63
- package/locale/zh.json +0 -63
package/lib/host/catalog.js
CHANGED
|
@@ -65,10 +65,17 @@ export function createSessionLogOps({ gateway, paths, config, now = Date.now, de
|
|
|
65
65
|
* Before moving a log this process believes is cold, re-check the newest
|
|
66
66
|
* write after a short grace window: a log that keeps growing is refused
|
|
67
67
|
* instead of being moved out from under the other writer.
|
|
68
|
+
*
|
|
69
|
+
* Live sessions are NOT exempt: `quiesce` (which runs before this step in
|
|
70
|
+
* the cascade) only stops THIS process's Agent. A conversation open in a
|
|
71
|
+
* second Host is exactly the writer this check must catch, and it is also
|
|
72
|
+
* the one this process sees as `live`, so the recheck runs for every mode.
|
|
73
|
+
* The grace window is normally already satisfied at that point, which makes
|
|
74
|
+
* the extra cost one mtime scan.
|
|
68
75
|
*/
|
|
69
76
|
async function assertNoForeignWriter(session, directory) {
|
|
70
77
|
const grace = Number.isFinite(config?.foreignWriterGraceMs) ? config.foreignWriterGraceMs : 0
|
|
71
|
-
if (grace <= 0
|
|
78
|
+
if (grace <= 0) return
|
|
72
79
|
const first = await latestWriteMs(directory)
|
|
73
80
|
if (first === null) return
|
|
74
81
|
const age = now() - first
|
|
@@ -135,6 +142,45 @@ export function createCatalogOps({ ctx, now = Date.now }) {
|
|
|
135
142
|
return registry().get(snapshot.workspaceId) ?? null
|
|
136
143
|
}
|
|
137
144
|
|
|
145
|
+
function isArchived(service, sessionId) {
|
|
146
|
+
try {
|
|
147
|
+
return Array.isArray(service.archivedSessionIds) && service.archivedSessionIds.includes(sessionId)
|
|
148
|
+
} catch {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isPinned(service, sessionId) {
|
|
154
|
+
try {
|
|
155
|
+
return Array.isArray(service.pinnedSessionIds) && service.pinnedSessionIds.includes(sessionId)
|
|
156
|
+
} catch {
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Put a session back at the position its snapshot recorded.
|
|
163
|
+
*
|
|
164
|
+
* `insertSessionBefore` requires the session to be accounted (the real Host
|
|
165
|
+
* throws `WorkspaceMoveInvalidError` otherwise), so this helper attaches
|
|
166
|
+
* first and only then moves it. Position is cosmetic: a failed move leaves
|
|
167
|
+
* the session attached, which is still correct.
|
|
168
|
+
*/
|
|
169
|
+
async function restorePosition(workspace, sessionId, nextSessionId) {
|
|
170
|
+
if (workspace === null) return
|
|
171
|
+
if (!workspace.sessionIds.includes(sessionId)) await workspace.attachSession(sessionId)
|
|
172
|
+
try {
|
|
173
|
+
if (nextSessionId === null || nextSessionId === undefined) {
|
|
174
|
+
await workspace.insertSessionBefore(sessionId, undefined)
|
|
175
|
+
} else if (workspace.sessionIds.includes(nextSessionId)) {
|
|
176
|
+
await workspace.insertSessionBefore(sessionId, nextSessionId)
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// The anchor disappeared or the workspace refused the move; the session
|
|
180
|
+
// is still attached, so only the ordering is lost.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
138
184
|
return {
|
|
139
185
|
/** Capture the exact catalog state before any mutation. */
|
|
140
186
|
async capture(session) {
|
|
@@ -177,25 +223,26 @@ export function createCatalogOps({ ctx, now = Date.now }) {
|
|
|
177
223
|
}
|
|
178
224
|
|
|
179
225
|
// Hard mode performs several mutations. Each one pushes its own inverse
|
|
180
|
-
// as it commits, so a failure halfway
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
226
|
+
// as it commits, so a failure halfway can put back exactly what this call
|
|
227
|
+
// changed instead of leaving the conversation visibly unarchived,
|
|
228
|
+
// unpinned or moved after a rolled back deletion.
|
|
229
|
+
const wasArchived = isArchived(service, session.id)
|
|
230
|
+
const wasPinned = isPinned(service, session.id)
|
|
184
231
|
const undone = []
|
|
185
232
|
const undoAll = async () => {
|
|
186
233
|
for (const step of [...undone].reverse()) await step()
|
|
187
234
|
}
|
|
188
235
|
try {
|
|
189
|
-
//
|
|
190
|
-
//
|
|
236
|
+
// Drop an existing archive marker (including the one quiesce adds for
|
|
237
|
+
// a live session) — EXCEPT for a live Agent: its pending inbox may
|
|
191
238
|
// still wake after this deletion. Keeping the archive gate as a
|
|
192
239
|
// tombstone makes that wake a blocked pre-step instead of a writer that
|
|
193
240
|
// recreates the moved log. Restore clears the tombstone.
|
|
194
|
-
if (session.live !== true) {
|
|
241
|
+
if (session.live !== true && wasArchived) {
|
|
195
242
|
await service.unarchiveSession(session.id)
|
|
196
|
-
|
|
243
|
+
undone.push(async () => { await service.archiveSession(session.id, { stopActivity: true }) })
|
|
197
244
|
}
|
|
198
|
-
if (
|
|
245
|
+
if (wasPinned) {
|
|
199
246
|
await service.unpinSession(session.id)
|
|
200
247
|
undone.push(async () => { await service.pinSession(session.id) })
|
|
201
248
|
}
|
|
@@ -205,11 +252,7 @@ export function createCatalogOps({ ctx, now = Date.now }) {
|
|
|
205
252
|
undone.push(async () => {
|
|
206
253
|
const current = workspaceOf(snapshot)
|
|
207
254
|
if (current === null) return
|
|
208
|
-
|
|
209
|
-
await current.insertSessionBefore(session.id, snapshot.nextSessionId ?? undefined)
|
|
210
|
-
} catch {
|
|
211
|
-
await current.attachSession(session.id)
|
|
212
|
-
}
|
|
255
|
+
await restorePosition(current, session.id, snapshot.nextSessionId ?? null)
|
|
213
256
|
})
|
|
214
257
|
}
|
|
215
258
|
} catch (error) {
|
|
@@ -221,39 +264,61 @@ export function createCatalogOps({ ctx, now = Date.now }) {
|
|
|
221
264
|
|
|
222
265
|
/**
|
|
223
266
|
* Rebuild the catalog state captured by `capture` (used by trash restore).
|
|
267
|
+
*
|
|
268
|
+
* The undo only reverts mutations THIS call made. A soft-deleted session is
|
|
269
|
+
* still attached to its workspace; an unconditional detach in the undo used
|
|
270
|
+
* to remove it from the workspace when a later (memory) step failed.
|
|
271
|
+
*
|
|
224
272
|
* @returns {Promise<() => Promise<void>>} undo
|
|
225
273
|
*/
|
|
226
274
|
async restore(snapshot) {
|
|
227
275
|
const service = registry()
|
|
276
|
+
const sessionId = snapshot.sessionId
|
|
277
|
+
const workspace = workspaceOf(snapshot)
|
|
278
|
+
const wasAttached = workspace !== null && workspace.sessionIds.includes(sessionId)
|
|
279
|
+
const wasArchived = isArchived(service, sessionId)
|
|
280
|
+
const wasPinned = isPinned(service, sessionId)
|
|
281
|
+
const changed = new Set()
|
|
228
282
|
try {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
await workspace.insertSessionBefore(snapshot.sessionId, snapshot.nextSessionId ?? undefined)
|
|
233
|
-
} catch {
|
|
234
|
-
await workspace.attachSession(snapshot.sessionId)
|
|
235
|
-
}
|
|
283
|
+
if (workspace !== null && !wasAttached) {
|
|
284
|
+
await workspace.attachSession(sessionId)
|
|
285
|
+
changed.add('attached')
|
|
236
286
|
}
|
|
237
|
-
if (
|
|
238
|
-
if (snapshot.
|
|
239
|
-
await service.
|
|
240
|
-
|
|
287
|
+
if (workspace !== null) await restorePosition(workspace, sessionId, snapshot.nextSessionId ?? null)
|
|
288
|
+
if (snapshot.pinned === true && !wasPinned) {
|
|
289
|
+
await service.pinSession(sessionId)
|
|
290
|
+
changed.add('pinned')
|
|
291
|
+
}
|
|
292
|
+
if (snapshot.archived === true) {
|
|
293
|
+
if (!wasArchived) {
|
|
294
|
+
await service.archiveSession(sessionId, { stopActivity: true })
|
|
295
|
+
changed.add('archived')
|
|
296
|
+
}
|
|
297
|
+
} else if (wasArchived) {
|
|
241
298
|
// A soft delete archived the session; restoring an entry whose
|
|
242
|
-
// pre-delete state was NOT archived must undo that archive.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
await service.unarchiveSession(snapshot.sessionId)
|
|
299
|
+
// pre-delete state was NOT archived must undo that archive.
|
|
300
|
+
await service.unarchiveSession(sessionId)
|
|
301
|
+
changed.add('unarchived')
|
|
246
302
|
}
|
|
247
303
|
return async () => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
304
|
+
if (changed.has('unarchived')) {
|
|
305
|
+
await service.archiveSession(sessionId, { stopActivity: true }).catch(() => {})
|
|
306
|
+
}
|
|
307
|
+
if (changed.has('archived')) {
|
|
308
|
+
await service.unarchiveSession(sessionId).catch(() => {})
|
|
309
|
+
}
|
|
310
|
+
if (changed.has('pinned')) {
|
|
311
|
+
await service.unpinSession(sessionId).catch(() => {})
|
|
312
|
+
}
|
|
313
|
+
if (changed.has('attached')) {
|
|
314
|
+
const current = workspaceOf(snapshot)
|
|
315
|
+
if (current !== null && current.sessionIds.includes(sessionId)) {
|
|
316
|
+
await current.detachSession(sessionId).catch(() => {})
|
|
317
|
+
}
|
|
251
318
|
}
|
|
252
|
-
if (snapshot.pinned) await service.unpinSession(snapshot.sessionId)
|
|
253
|
-
if (snapshot.archived) await service.unarchiveSession(snapshot.sessionId)
|
|
254
319
|
}
|
|
255
320
|
} catch (error) {
|
|
256
|
-
throw wrapCatalogError(error, 'restore',
|
|
321
|
+
throw wrapCatalogError(error, 'restore', sessionId)
|
|
257
322
|
}
|
|
258
323
|
},
|
|
259
324
|
}
|
package/lib/host/gateway.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { findSessionDir, readJsonFile } from './paths.js'
|
|
17
17
|
import { join } from 'node:path'
|
|
18
|
+
import { isPathInside } from '../core/policy.js'
|
|
18
19
|
|
|
19
20
|
const LIST_TTL_MS = 1_500
|
|
20
21
|
const STATE_TTL_MS = 1_500
|
|
@@ -126,10 +127,21 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
126
127
|
if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, { workspace, index })
|
|
127
128
|
})
|
|
128
129
|
}
|
|
130
|
+
// Ownership is also decided by cwd containment, exactly like `inspect`'s
|
|
131
|
+
// `workspaceFor` fallback: a session whose directory moved out of every
|
|
132
|
+
// registered workspace is an orphan, and `/sessions` must say so per row.
|
|
133
|
+
// Without this, `evaluateSessionPolicy` saw `workspace: undefined` on every
|
|
134
|
+
// list row and marked the whole batch picker non-deletable.
|
|
135
|
+
const workspaceByCwd = (cwd) => {
|
|
136
|
+
if (typeof cwd !== 'string' || cwd === '') return null
|
|
137
|
+
return workspaces.find((workspace) => isPathInside(workspace.path, cwd)) ?? null
|
|
138
|
+
}
|
|
129
139
|
const titles = await readTitles()
|
|
130
140
|
const state = await readWorkspaceState()
|
|
131
141
|
listCache = items.map((item) => {
|
|
132
|
-
const
|
|
142
|
+
const member = workspaceBySession.get(item.sessionId) ?? null
|
|
143
|
+
const byCwd = member === null ? workspaceByCwd(item.cwd) : null
|
|
144
|
+
const owned = member ?? (byCwd === null ? null : { workspace: byCwd, index: -1 })
|
|
133
145
|
const cached = titles.get(item.sessionId) ?? { title: '', blank: item.blank === true }
|
|
134
146
|
return {
|
|
135
147
|
id: String(item.sessionId),
|
|
@@ -145,7 +157,9 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
145
157
|
updatedAt: item.updatedAt ?? null,
|
|
146
158
|
events: null,
|
|
147
159
|
sizeBytes: null,
|
|
160
|
+
workspace: owned === null ? null : { id: owned.workspace.id, title: owned.workspace.title, path: owned.workspace.path, index: owned.index },
|
|
148
161
|
workspaceId: owned?.workspace?.id ?? null,
|
|
162
|
+
workspacePath: owned?.workspace?.path ?? null,
|
|
149
163
|
workspaceIndex: owned?.index ?? -1,
|
|
150
164
|
}
|
|
151
165
|
})
|
|
@@ -169,9 +183,9 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
169
183
|
if (byId !== undefined) return byId
|
|
170
184
|
const byCwd = workspaces.find((workspace) => {
|
|
171
185
|
if (typeof workspace.path !== 'string' || typeof session.cwd !== 'string') return false
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return
|
|
186
|
+
// Shared path semantics: drive-letter paths compare case-insensitively,
|
|
187
|
+
// POSIX paths stay case-sensitive, and UNC roots are handled like Windows.
|
|
188
|
+
return isPathInside(workspace.path, session.cwd)
|
|
175
189
|
})
|
|
176
190
|
return byCwd ?? null
|
|
177
191
|
}
|
|
@@ -182,7 +196,7 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
182
196
|
|
|
183
197
|
/** Normalize one session; throws E_NOT_FOUND-shaped data as `null`. */
|
|
184
198
|
async inspect(sessionId) {
|
|
185
|
-
if (typeof sessionId !== 'string' || !/^[A-Za-z0-
|
|
199
|
+
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{6,80}$/.test(sessionId)) return null
|
|
186
200
|
const persistence = service('sessionPersistence')
|
|
187
201
|
let snapshot
|
|
188
202
|
if (persistence !== undefined && typeof persistence.stat === 'function') {
|
package/lib/host/paths.js
CHANGED
|
@@ -12,11 +12,22 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { cp, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises'
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
|
-
import { dirname, join } from 'node:path'
|
|
15
|
+
import { dirname, join, resolve } from 'node:path'
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the harness home exactly like `@deepseek-ai/dsh-home-paths`:
|
|
19
|
+
* explicit `$DSH_HOME` (with `~` expansion) wins over `~/.dsh`, and the result
|
|
20
|
+
* is absolute. A blank override counts as unset and must never resolve to the
|
|
21
|
+
* current working directory.
|
|
22
|
+
*/
|
|
17
23
|
export function resolveDshHome(env = process.env, home = homedir()) {
|
|
18
24
|
const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
|
|
19
|
-
|
|
25
|
+
if (configured === '') return join(home, '.dsh')
|
|
26
|
+
if (configured === '~') return resolve(home)
|
|
27
|
+
if (configured.startsWith('~/') || configured.startsWith('~\\')) {
|
|
28
|
+
return resolve(join(home, configured.slice(2)))
|
|
29
|
+
}
|
|
30
|
+
return resolve(configured)
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
/**
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/HERO476/dsh-conversation-recycle-bin.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/HERO476/dsh-conversation-recycle-bin#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/HERO476/dsh-conversation-recycle-bin/issues"
|
|
12
|
+
},
|
|
13
|
+
"author": "HERO476",
|
|
5
14
|
"type": "module",
|
|
6
15
|
"main": "lib/index.js",
|
|
7
16
|
"exports": {
|
|
@@ -9,7 +18,6 @@
|
|
|
9
18
|
"./client": "./client.js",
|
|
10
19
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
20
|
"./package.json": "./package.json",
|
|
12
|
-
"./locale/*.json": "./locale/*.json",
|
|
13
21
|
"./icon": "./icon.svg"
|
|
14
22
|
},
|
|
15
23
|
"files": [
|
|
@@ -17,11 +25,11 @@
|
|
|
17
25
|
"client.js",
|
|
18
26
|
"cordis.patch.yml",
|
|
19
27
|
"config.schema.json",
|
|
20
|
-
"locale/*.json",
|
|
21
28
|
"icon.svg",
|
|
22
29
|
"README.md",
|
|
23
30
|
"INSTALL.md",
|
|
24
31
|
"CHANGELOG.md",
|
|
32
|
+
"AUDIT-2026-10-17-desktop-web.md",
|
|
25
33
|
"LICENSE"
|
|
26
34
|
],
|
|
27
35
|
"engines": {
|
package/locale/en.json
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"meta": {
|
|
3
|
-
"title": "Delete Conversation",
|
|
4
|
-
"description": "Delete a conversation with an optional cascade into its associated memory. Soft delete and recycle bin, audit trail, batch rate limits, and per-session rollback."
|
|
5
|
-
},
|
|
6
|
-
"section": {
|
|
7
|
-
"label": "Conversation Deletion",
|
|
8
|
-
"title": "Conversation Deletion",
|
|
9
|
-
"subtitle": "Recycle bin, batch deletion and audit trail."
|
|
10
|
-
},
|
|
11
|
-
"action": {
|
|
12
|
-
"menuDelete": "Delete conversation…",
|
|
13
|
-
"headerDelete": "Delete…",
|
|
14
|
-
"delete": "Delete",
|
|
15
|
-
"cancel": "Cancel",
|
|
16
|
-
"close": "Close",
|
|
17
|
-
"restore": "Restore",
|
|
18
|
-
"purge": "Delete permanently",
|
|
19
|
-
"refresh": "Refresh",
|
|
20
|
-
"selectAll": "Select all"
|
|
21
|
-
},
|
|
22
|
-
"dialog": {
|
|
23
|
-
"titleOne": "Delete this conversation?",
|
|
24
|
-
"titleMany": "Delete {count} conversations?",
|
|
25
|
-
"bodyOne": "\u201c{title}\u201d will be removed from the session list.",
|
|
26
|
-
"bodyMany": "{count} conversations will be removed from the session list.",
|
|
27
|
-
"modeSoft": "Move to recycle bin (recommended)",
|
|
28
|
-
"modeSoftHint": "Files stay on disk and can be restored from the recycle bin.",
|
|
29
|
-
"modeHard": "Delete permanently",
|
|
30
|
-
"modeHardHint": "Moves the session log out of the live store. Recoverable until the recycle-bin entry is purged.",
|
|
31
|
-
"memoryLabel": "Also delete this conversation\u2019s associated memory",
|
|
32
|
-
"memoryHint": "Off by default. Memory entries are counted before you confirm.",
|
|
33
|
-
"memoryCount": "{count} associated memory entries will be moved to the recycle bin",
|
|
34
|
-
"memoryNone": "No associated memory entries were found",
|
|
35
|
-
"memoryUnsupported": "Memory store not available: {reason}",
|
|
36
|
-
"memoryProtected": "{count} protected entries are kept",
|
|
37
|
-
"messages": "{events} records · {size}",
|
|
38
|
-
"hardWarning": "Type DELETE to confirm permanent deletion.",
|
|
39
|
-
"batchWarning": "Type the number {count} to confirm this batch.",
|
|
40
|
-
"batchPhrase": "Batch size",
|
|
41
|
-
"working": "Deleting…",
|
|
42
|
-
"done": "Deleted {count} conversation(s).",
|
|
43
|
-
"partial": "Deleted {done}, failed {failed}.",
|
|
44
|
-
"memoryDeleted": "{count} memory entries moved to the recycle bin.",
|
|
45
|
-
"error": "Deletion failed: {message}"
|
|
46
|
-
},
|
|
47
|
-
"trash": {
|
|
48
|
-
"title": "Recycle bin",
|
|
49
|
-
"empty": "The recycle bin is empty.",
|
|
50
|
-
"deletedAt": "Deleted {time}",
|
|
51
|
-
"items": "{sessions} conversations, {memories} memory entries",
|
|
52
|
-
"restoreDone": "Restored.",
|
|
53
|
-
"purgeDone": "Permanently deleted.",
|
|
54
|
-
"confirmPurge": "Permanently delete this entry? This cannot be undone.",
|
|
55
|
-
"auditTitle": "Audit trail",
|
|
56
|
-
"auditEmpty": "No deletion has been recorded yet."
|
|
57
|
-
},
|
|
58
|
-
"batch": {
|
|
59
|
-
"title": "Batch deletion",
|
|
60
|
-
"hint": "Select conversations to delete. Deletion is per-conversation atomic; a failure rolls that conversation back.",
|
|
61
|
-
"selected": "{count} selected"
|
|
62
|
-
}
|
|
63
|
-
}
|
package/locale/zh.json
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"meta": {
|
|
3
|
-
"title": "删除对话",
|
|
4
|
-
"description": "删除对话,并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计留痕、批量限流与失败回滚。"
|
|
5
|
-
},
|
|
6
|
-
"section": {
|
|
7
|
-
"label": "对话删除",
|
|
8
|
-
"title": "对话删除",
|
|
9
|
-
"subtitle": "回收站、批量删除与审计留痕。"
|
|
10
|
-
},
|
|
11
|
-
"action": {
|
|
12
|
-
"menuDelete": "删除对话…",
|
|
13
|
-
"headerDelete": "删除…",
|
|
14
|
-
"delete": "删除",
|
|
15
|
-
"cancel": "取消",
|
|
16
|
-
"close": "关闭",
|
|
17
|
-
"restore": "恢复",
|
|
18
|
-
"purge": "彻底删除",
|
|
19
|
-
"refresh": "刷新",
|
|
20
|
-
"selectAll": "全选"
|
|
21
|
-
},
|
|
22
|
-
"dialog": {
|
|
23
|
-
"titleOne": "删除这个对话?",
|
|
24
|
-
"titleMany": "删除 {count} 个对话?",
|
|
25
|
-
"bodyOne": "「{title}」将从会话列表中移除。",
|
|
26
|
-
"bodyMany": "{count} 个对话将从会话列表中移除。",
|
|
27
|
-
"modeSoft": "移入回收站(推荐)",
|
|
28
|
-
"modeSoftHint": "文件仍保留在磁盘上,可从回收站恢复。",
|
|
29
|
-
"modeHard": "彻底删除",
|
|
30
|
-
"modeHardHint": "把会话日志移出活动存储;回收站条目被清理前仍可恢复。",
|
|
31
|
-
"memoryLabel": "同时删除该对话的关联记忆",
|
|
32
|
-
"memoryHint": "默认不勾选。确认前会实时统计受影响的记忆条数。",
|
|
33
|
-
"memoryCount": "将把 {count} 条关联记忆移入回收站",
|
|
34
|
-
"memoryNone": "未找到关联记忆",
|
|
35
|
-
"memoryUnsupported": "记忆存储不可用:{reason}",
|
|
36
|
-
"memoryProtected": "另有 {count} 条受保护记忆将保留",
|
|
37
|
-
"messages": "{events} 条记录 · {size}",
|
|
38
|
-
"hardWarning": "彻底删除需输入 DELETE 确认。",
|
|
39
|
-
"batchWarning": "批量删除需输入数字 {count} 确认。",
|
|
40
|
-
"batchPhrase": "批量数量",
|
|
41
|
-
"working": "正在删除…",
|
|
42
|
-
"done": "已删除 {count} 个对话。",
|
|
43
|
-
"partial": "成功 {done} 个,失败 {failed} 个。",
|
|
44
|
-
"memoryDeleted": "已移入回收站的记忆条目:{count} 条。",
|
|
45
|
-
"error": "删除失败:{message}"
|
|
46
|
-
},
|
|
47
|
-
"trash": {
|
|
48
|
-
"title": "回收站",
|
|
49
|
-
"empty": "回收站为空。",
|
|
50
|
-
"deletedAt": "删除于 {time}",
|
|
51
|
-
"items": "{sessions} 个对话、{memories} 条记忆",
|
|
52
|
-
"restoreDone": "已恢复。",
|
|
53
|
-
"purgeDone": "已彻底删除。",
|
|
54
|
-
"confirmPurge": "彻底删除该条目?此操作不可撤销。",
|
|
55
|
-
"auditTitle": "审计留痕",
|
|
56
|
-
"auditEmpty": "尚无删除记录。"
|
|
57
|
-
},
|
|
58
|
-
"batch": {
|
|
59
|
-
"title": "批量删除",
|
|
60
|
-
"hint": "勾选要删除的对话。每个对话独立事务,失败时仅回滚该对话。",
|
|
61
|
-
"selected": "已选 {count} 个"
|
|
62
|
-
}
|
|
63
|
-
}
|