dsh-chat-manager-wide 1.4.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/AGENTS.md +127 -0
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/PUBLISH.md +167 -0
- package/README.en.md +151 -0
- package/README.md +139 -0
- package/README.zh-CN.md +5 -0
- package/SECURITY.md +15 -0
- package/THIRD_PARTY_NOTICES.md +63 -0
- package/compatibility.json +31 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +6209 -0
- package/package.json +159 -0
- package/scripts/build-client-local.mjs +38 -0
- package/scripts/build-client.mjs +450 -0
- package/scripts/smoke-ui.mjs +221 -0
- package/src/host/archive-manager.mjs +329 -0
- package/src/host/delete-session.mjs +430 -0
- package/src/index.js +97 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { chromium } from 'playwright'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const HELP = `Usage:
|
|
6
|
+
pnpm smoke:ui -- --session "Exact session title" [options]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
--url <url> Running DSH URL (default: http://127.0.0.1:14171)
|
|
10
|
+
--session <title> Exact existing session title (required)
|
|
11
|
+
--channel <name> Browser channel such as chrome (optional)
|
|
12
|
+
--executable <path> Existing Chromium-family executable (optional)
|
|
13
|
+
--headed Show the browser window
|
|
14
|
+
--screenshot <path> Save the opened confirmation dialog
|
|
15
|
+
--simulate-delete-success
|
|
16
|
+
On a ?fixture page only, intercept the delete request,
|
|
17
|
+
click the final button, and verify no page reload
|
|
18
|
+
--help Show this help
|
|
19
|
+
|
|
20
|
+
By default this check opens Delete session, verifies the confirmation dialog,
|
|
21
|
+
and clicks Cancel. The simulation mode never reaches the Host deletion route or
|
|
22
|
+
controls DSH's process lifecycle.
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
export function parseArgs(argv) {
|
|
26
|
+
const result = { url: 'http://127.0.0.1:14171', headed: false }
|
|
27
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
28
|
+
const arg = argv[index]
|
|
29
|
+
if (arg === '--') continue
|
|
30
|
+
if (arg === '--help') return { help: true }
|
|
31
|
+
if (arg === '--headed') {
|
|
32
|
+
result.headed = true
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (arg === '--simulate-delete-success') {
|
|
36
|
+
result.simulateDeleteSuccess = true
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (['--url', '--session', '--channel', '--executable', '--screenshot'].includes(arg)) {
|
|
40
|
+
const value = argv[index + 1]
|
|
41
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${arg} requires a value`)
|
|
42
|
+
result[arg.slice(2)] = value
|
|
43
|
+
index += 1
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`unknown argument: ${arg}`)
|
|
47
|
+
}
|
|
48
|
+
if (typeof result.session !== 'string' || result.session.length === 0) {
|
|
49
|
+
throw new Error('--session is required so the smoke test never chooses a user session implicitly')
|
|
50
|
+
}
|
|
51
|
+
if (result.channel !== undefined && result.executable !== undefined) {
|
|
52
|
+
throw new Error('use either --channel or --executable, not both')
|
|
53
|
+
}
|
|
54
|
+
if (result.simulateDeleteSuccess === true) {
|
|
55
|
+
if (!isFixtureUrl(result.url)) {
|
|
56
|
+
throw new Error('--simulate-delete-success requires a DSH fixture URL so it cannot target user sessions')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isFixtureUrl(url) {
|
|
63
|
+
return new URL(url).searchParams.has('fixture')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isIgnorableFixtureConsoleError(message) {
|
|
67
|
+
return (
|
|
68
|
+
message.includes('[cordis-client-runner] syncing inspect providers failed:')
|
|
69
|
+
&& message.includes('fixture connection RPC endpoint "dynamicCordisRunner/syncInspectManifest" is unavailable')
|
|
70
|
+
) || (
|
|
71
|
+
message.includes('[ui-cordis] reading the Cordis inventory failed:')
|
|
72
|
+
&& message.includes('fixture connection RPC endpoint "dynamicCordisRunner/inventory" is unavailable')
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runSmoke(options) {
|
|
77
|
+
const deleteRequests = []
|
|
78
|
+
const consoleErrors = []
|
|
79
|
+
const fixture = isFixtureUrl(options.url)
|
|
80
|
+
const browser = await chromium.launch({
|
|
81
|
+
headless: !options.headed,
|
|
82
|
+
...(options.channel === undefined ? {} : { channel: options.channel }),
|
|
83
|
+
...(options.executable === undefined ? {} : { executablePath: options.executable }),
|
|
84
|
+
})
|
|
85
|
+
try {
|
|
86
|
+
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
|
87
|
+
let navigationArmed = false
|
|
88
|
+
let mainFrameNavigations = 0
|
|
89
|
+
if (options.simulateDeleteSuccess === true) {
|
|
90
|
+
await page.addInitScript(() => {
|
|
91
|
+
globalThis.__dshDeleteSmokeDocumentToken = crypto.randomUUID()
|
|
92
|
+
})
|
|
93
|
+
await page.route('**/plugins/dsh-session-delete/delete', async (route) => {
|
|
94
|
+
await route.fulfill({
|
|
95
|
+
status: 200,
|
|
96
|
+
contentType: 'application/json',
|
|
97
|
+
body: JSON.stringify({ ok: true, value: { deleted: true } }),
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
page.on('framenavigated', (frame) => {
|
|
101
|
+
if (navigationArmed && frame === page.mainFrame()) mainFrameNavigations += 1
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
page.on('request', (request) => {
|
|
105
|
+
if (new URL(request.url()).pathname === '/plugins/dsh-session-delete/delete') {
|
|
106
|
+
deleteRequests.push(request.method())
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
page.on('console', (message) => {
|
|
110
|
+
if (message.type() === 'error') consoleErrors.push(message.text())
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
await page.goto(options.url, { waitUntil: 'domcontentloaded' })
|
|
114
|
+
const documentToken = options.simulateDeleteSuccess === true
|
|
115
|
+
? await page.evaluate(() => globalThis.__dshDeleteSmokeDocumentToken)
|
|
116
|
+
: undefined
|
|
117
|
+
if (fixture) {
|
|
118
|
+
// Fixture mode cannot persist DSH's product-wide onboarding acknowledgement,
|
|
119
|
+
// so clicking Continue immediately reopens the unrelated notice. Remove only
|
|
120
|
+
// that exact fixture-only overlay; never mutate real settings or user pages.
|
|
121
|
+
const onboarding = page.getByRole('dialog', { name: /^(内测声明|Internal Testing Notice)$/ })
|
|
122
|
+
const onboardingVisible = await onboarding
|
|
123
|
+
.waitFor({ state: 'visible', timeout: 3000 })
|
|
124
|
+
.then(() => true, () => false)
|
|
125
|
+
if (onboardingVisible) {
|
|
126
|
+
await onboarding.evaluate((element) => {
|
|
127
|
+
const overlay = element.parentElement
|
|
128
|
+
if (overlay !== null) overlay.remove()
|
|
129
|
+
else element.remove()
|
|
130
|
+
for (const inertElement of document.querySelectorAll('[inert]')) {
|
|
131
|
+
inertElement.removeAttribute('inert')
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await page.locator('#archived-sessions').click()
|
|
137
|
+
const archiveDialog = page.getByRole('dialog', { name: /^(Archived sessions|归档会话)$/ })
|
|
138
|
+
await archiveDialog.getByRole('searchbox').waitFor()
|
|
139
|
+
await archiveDialog.getByRole('button', { name: /^(Close|关闭)$/ }).filter({ hasText: /^(Close|关闭)$/ }).click()
|
|
140
|
+
await archiveDialog.waitFor({ state: 'hidden' })
|
|
141
|
+
const matchingTitles = page.getByText(options.session, { exact: true })
|
|
142
|
+
await matchingTitles.first().waitFor()
|
|
143
|
+
const rowCount = await matchingTitles.count()
|
|
144
|
+
if (rowCount !== 1) {
|
|
145
|
+
throw new Error(`expected exactly one visible session row named ${JSON.stringify(options.session)}, found ${rowCount}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const row = matchingTitles.first().locator('xpath=ancestor::*[@role="treeitem"][1]')
|
|
149
|
+
await row.hover()
|
|
150
|
+
const actions = row.getByRole('button')
|
|
151
|
+
if (await actions.count() !== 1) throw new Error('session action button was not uniquely identifiable')
|
|
152
|
+
await actions.click()
|
|
153
|
+
|
|
154
|
+
const archiveItem = page.getByRole('menuitem', { name: /^(Archive session|归档会话)$/ })
|
|
155
|
+
await archiveItem.waitFor()
|
|
156
|
+
const deleteItem = page.getByRole('menuitem', { name: /^(Delete session|删除会话)$/ })
|
|
157
|
+
await deleteItem.waitFor()
|
|
158
|
+
const [archiveColor, deleteColor] = await Promise.all([
|
|
159
|
+
archiveItem.evaluate((element) => getComputedStyle(element).color),
|
|
160
|
+
deleteItem.evaluate((element) => getComputedStyle(element).color),
|
|
161
|
+
])
|
|
162
|
+
if (archiveColor === deleteColor) {
|
|
163
|
+
throw new Error(`delete menu item is not using a distinct danger color (${deleteColor})`)
|
|
164
|
+
}
|
|
165
|
+
await deleteItem.click()
|
|
166
|
+
|
|
167
|
+
const dialog = page.getByRole('dialog')
|
|
168
|
+
await dialog.getByText(/^(Permanently delete session\?|永久删除会话?)$/).waitFor()
|
|
169
|
+
await dialog.getByRole('button', { name: /^(Delete permanently|永久删除)$/ }).waitFor()
|
|
170
|
+
const cancel = dialog.getByRole('button', { name: /^(Cancel|取消)$/ })
|
|
171
|
+
await cancel.waitFor()
|
|
172
|
+
if (options.screenshot !== undefined) await dialog.screenshot({ path: options.screenshot })
|
|
173
|
+
if (options.simulateDeleteSuccess === true) {
|
|
174
|
+
navigationArmed = true
|
|
175
|
+
await dialog.getByRole('button', { name: /^(Delete permanently|永久删除)$/ }).click()
|
|
176
|
+
await page.waitForTimeout(750)
|
|
177
|
+
const settledToken = await page.evaluate(() => globalThis.__dshDeleteSmokeDocumentToken)
|
|
178
|
+
if (deleteRequests.length !== 1) {
|
|
179
|
+
throw new Error(`simulated success expected one delete request, observed ${deleteRequests.length}`)
|
|
180
|
+
}
|
|
181
|
+
if (mainFrameNavigations !== 0 || settledToken !== documentToken) {
|
|
182
|
+
throw new Error(`successful deletion reloaded the WebView (${mainFrameNavigations} main-frame navigation(s))`)
|
|
183
|
+
}
|
|
184
|
+
await dialog.getByText(/^(Permanently delete session\?|永久删除会话?)$/).waitFor({ state: 'hidden' })
|
|
185
|
+
} else {
|
|
186
|
+
await cancel.click()
|
|
187
|
+
await dialog.waitFor({ state: 'hidden' })
|
|
188
|
+
if (deleteRequests.length > 0) {
|
|
189
|
+
throw new Error(`cancel path unexpectedly sent ${deleteRequests.length} delete request(s)`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const blockingConsoleErrors = fixture
|
|
193
|
+
? consoleErrors.filter((message) => !isIgnorableFixtureConsoleError(message))
|
|
194
|
+
: consoleErrors
|
|
195
|
+
if (blockingConsoleErrors.length > 0) {
|
|
196
|
+
throw new Error(`browser console errors: ${blockingConsoleErrors.join(' | ')}`)
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
checks: options.simulateDeleteSuccess === true
|
|
201
|
+
? ['archive manager', 'Archive session', 'red Delete session', 'confirmation dialog', 'successful delete without reload']
|
|
202
|
+
: ['archive manager', 'Archive session', 'red Delete session', 'confirmation dialog', 'cancel without request'],
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
await browser.close()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
210
|
+
try {
|
|
211
|
+
const options = parseArgs(process.argv.slice(2))
|
|
212
|
+
if (options.help) {
|
|
213
|
+
process.stdout.write(HELP)
|
|
214
|
+
} else {
|
|
215
|
+
process.stdout.write(`${JSON.stringify(await runSmoke(options))}\n`)
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
219
|
+
process.exitCode = 1
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
const SEARCH_LIMIT = 20
|
|
2
|
+
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
|
3
|
+
const MAX_REQUEST_BYTES = 8 * 1024
|
|
4
|
+
|
|
5
|
+
const assertSessionId = sessionId => {
|
|
6
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 512 || sessionId.includes('\0')) {
|
|
7
|
+
throw new TypeError('invalid archived session id')
|
|
8
|
+
}
|
|
9
|
+
return sessionId
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const archiveIds = workspaceRegistry => {
|
|
13
|
+
const ids = workspaceRegistry?.archivedSessionIds
|
|
14
|
+
if (!Array.isArray(ids) || ids.some(id => typeof id !== 'string')) {
|
|
15
|
+
throw new Error('unsupported workspace registry archive snapshot')
|
|
16
|
+
}
|
|
17
|
+
return [...ids]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const plainSnippet = (text, query) => {
|
|
21
|
+
const compact = text.replace(/\s+/g, ' ').trim()
|
|
22
|
+
if (compact.length <= 240) return compact
|
|
23
|
+
const matchAt = compact.toLowerCase().indexOf(query.toLowerCase())
|
|
24
|
+
const start = Math.max(0, (matchAt === -1 ? 0 : matchAt) - 80)
|
|
25
|
+
const end = Math.min(compact.length, start + 240)
|
|
26
|
+
return `${start > 0 ? '…' : ''}${compact.slice(start, end)}${end < compact.length ? '…' : ''}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const scanArchivedEvents = async (sessionQuery, archivedSessionIds, query, signal) => {
|
|
30
|
+
if (typeof sessionQuery?.filterEvents !== 'function') {
|
|
31
|
+
throw new Error('archived history scan is unavailable')
|
|
32
|
+
}
|
|
33
|
+
const filters = [
|
|
34
|
+
{ kind: 'type', values: ['user/message', 'assistant/message'] },
|
|
35
|
+
{ kind: 'surface', values: ['current'] },
|
|
36
|
+
{ kind: 'text', text: query },
|
|
37
|
+
]
|
|
38
|
+
const items = []
|
|
39
|
+
for (let offset = 0; offset < archivedSessionIds.length && items.length <= SEARCH_LIMIT; offset += 4) {
|
|
40
|
+
signal?.throwIfAborted()
|
|
41
|
+
const batch = archivedSessionIds.slice(offset, offset + 4)
|
|
42
|
+
const matches = await Promise.all(batch.map(sessionId => sessionQuery.filterEvents(sessionId, filters)))
|
|
43
|
+
signal?.throwIfAborted()
|
|
44
|
+
for (let index = 0; index < batch.length; index += 1) {
|
|
45
|
+
const match = Array.isArray(matches[index])
|
|
46
|
+
? matches[index].find(event => typeof event?.text === 'string' && event.text.trim().length > 0)
|
|
47
|
+
: undefined
|
|
48
|
+
if (match !== undefined) items.push({ sessionId: batch[index], snippet: plainSnippet(match.text, query) })
|
|
49
|
+
if (items.length > SEARCH_LIMIT) break
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { items: items.slice(0, SEARCH_LIMIT), hasMore: items.length > SEARCH_LIMIT }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove one id from DSH's registry-global archive set without touching the
|
|
57
|
+
* session log or its workspace accounting position. Prefer the public DSH
|
|
58
|
+
* unarchive operation; older cores retain the validated registry fallback.
|
|
59
|
+
*/
|
|
60
|
+
export async function restoreArchivedSession(workspaceRegistry, sessionId) {
|
|
61
|
+
const id = assertSessionId(sessionId)
|
|
62
|
+
if (typeof workspaceRegistry?.unarchiveSession === 'function') {
|
|
63
|
+
const wasArchived = archiveIds(workspaceRegistry).includes(id)
|
|
64
|
+
await workspaceRegistry.unarchiveSession(id)
|
|
65
|
+
return { restored: wasArchived, archivedSessionIds: [...archiveIds(workspaceRegistry)] }
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
typeof workspaceRegistry?.enqueueOperation !== 'function'
|
|
69
|
+
|| typeof workspaceRegistry?.requireState !== 'function'
|
|
70
|
+
|| typeof workspaceRegistry?.setState !== 'function'
|
|
71
|
+
) {
|
|
72
|
+
throw new Error('unsupported workspace registry restore seam')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return workspaceRegistry.enqueueOperation(async () => {
|
|
76
|
+
const state = workspaceRegistry.requireState()
|
|
77
|
+
if (!Array.isArray(state?.archivedSessionIds)) {
|
|
78
|
+
throw new Error('unsupported workspace registry restore state')
|
|
79
|
+
}
|
|
80
|
+
if (!state.archivedSessionIds.includes(id)) {
|
|
81
|
+
return { restored: false, archivedSessionIds: [...state.archivedSessionIds] }
|
|
82
|
+
}
|
|
83
|
+
const archivedSessionIds = state.archivedSessionIds.filter(candidate => candidate !== id)
|
|
84
|
+
await workspaceRegistry.setState({ ...state, archivedSessionIds })
|
|
85
|
+
return { restored: true, archivedSessionIds }
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Keep DSH's archive registry free of ids whose storage was permanently removed. */
|
|
90
|
+
export async function deleteSessionAndReconcileArchive({ workspaceRegistry, deleteSession, warn = console.warn }, sessionId) {
|
|
91
|
+
const result = await deleteSession(sessionId)
|
|
92
|
+
if (result?.ok !== true) return result
|
|
93
|
+
try {
|
|
94
|
+
await restoreArchivedSession(workspaceRegistry, sessionId)
|
|
95
|
+
return { ok: true, value: { ...result.value, archiveReconciled: true } }
|
|
96
|
+
} catch (error) {
|
|
97
|
+
warn('session storage was deleted but its archive marker could not be reconciled:', error)
|
|
98
|
+
return { ok: true, value: { ...result.value, archiveReconciled: false } }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Search current user/assistant message history inside the archive set only. */
|
|
103
|
+
export async function searchArchivedSessions({ workspaceRegistry, sessionQuery }, query, signal) {
|
|
104
|
+
signal?.throwIfAborted()
|
|
105
|
+
const normalized = typeof query === 'string' ? query.trim() : ''
|
|
106
|
+
if (normalized.length === 0 || normalized.length > 500 || normalized.includes('\0')) {
|
|
107
|
+
throw new TypeError('invalid archive search query')
|
|
108
|
+
}
|
|
109
|
+
if (typeof sessionQuery?.searchSessions !== 'function') {
|
|
110
|
+
throw new Error('archived history search is unavailable')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const archivedSessionIds = archiveIds(workspaceRegistry)
|
|
114
|
+
if (archivedSessionIds.length === 0) return { items: [], hasMore: false }
|
|
115
|
+
|
|
116
|
+
let page
|
|
117
|
+
try {
|
|
118
|
+
page = await sessionQuery.searchSessions({
|
|
119
|
+
query: normalized,
|
|
120
|
+
sessionFilters: [{ kind: 'id', values: archivedSessionIds }],
|
|
121
|
+
eventFilters: [
|
|
122
|
+
{ kind: 'type', values: ['user/message', 'assistant/message'] },
|
|
123
|
+
{ kind: 'surface', values: ['current'] },
|
|
124
|
+
],
|
|
125
|
+
limit: SEARCH_LIMIT,
|
|
126
|
+
}, { signal })
|
|
127
|
+
signal?.throwIfAborted()
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error?.code !== 'SESSION_QUERY_SEARCH_DISABLED') throw error
|
|
130
|
+
return scanArchivedEvents(sessionQuery, archivedSessionIds, normalized, signal)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const archived = new Set(archivedSessionIds)
|
|
134
|
+
const items = []
|
|
135
|
+
const included = new Set()
|
|
136
|
+
for (const hit of Array.isArray(page?.items) ? page.items : []) {
|
|
137
|
+
const sessionId = hit?.header?.id
|
|
138
|
+
const match = hit?.bestMatch
|
|
139
|
+
if (
|
|
140
|
+
typeof sessionId !== 'string'
|
|
141
|
+
|| !archived.has(sessionId)
|
|
142
|
+
|| included.has(sessionId)
|
|
143
|
+
|| match?.sessionId !== sessionId
|
|
144
|
+
|| match?.surface !== 'current'
|
|
145
|
+
|| !MESSAGE_TYPES.has(match?.type)
|
|
146
|
+
|| typeof match?.snippet !== 'string'
|
|
147
|
+
) continue
|
|
148
|
+
included.add(sessionId)
|
|
149
|
+
items.push({ sessionId, snippet: match.snippet })
|
|
150
|
+
if (items.length >= SEARCH_LIMIT) break
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { items, hasMore: page?.nextCursor !== undefined }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const DETAIL_MESSAGE_LIMIT = 4000
|
|
157
|
+
const DETAIL_TEXT_LIMIT = 40000
|
|
158
|
+
const DETAIL_TOTAL_TEXT_LIMIT = 4 * 1024 * 1024
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read the current user/assistant transcript of one archived session.
|
|
162
|
+
* Archive membership is re-checked here so this read stays inside the archive set.
|
|
163
|
+
*/
|
|
164
|
+
export async function readArchivedSessionDetail({ workspaceRegistry, sessionQuery }, sessionId) {
|
|
165
|
+
const id = assertSessionId(sessionId)
|
|
166
|
+
if (!archiveIds(workspaceRegistry).includes(id)) {
|
|
167
|
+
throw new TypeError('session is not archived')
|
|
168
|
+
}
|
|
169
|
+
if (typeof sessionQuery?.filterEvents !== 'function') {
|
|
170
|
+
throw new Error('archived history read is unavailable')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const documents = await sessionQuery.filterEvents(id, [
|
|
174
|
+
{ kind: 'type', values: ['user/message', 'assistant/message'] },
|
|
175
|
+
{ kind: 'surface', values: ['current'] },
|
|
176
|
+
])
|
|
177
|
+
const found = Array.isArray(documents) ? documents : []
|
|
178
|
+
const items = []
|
|
179
|
+
let textBudget = 0
|
|
180
|
+
let truncated = false
|
|
181
|
+
for (const document of found) {
|
|
182
|
+
if (items.length >= DETAIL_MESSAGE_LIMIT) {
|
|
183
|
+
truncated = true
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
const text = typeof document?.text === 'string' ? document.text : ''
|
|
187
|
+
if (text.trim().length === 0) continue
|
|
188
|
+
let body = text
|
|
189
|
+
if (body.length > DETAIL_TEXT_LIMIT) {
|
|
190
|
+
body = `${body.slice(0, DETAIL_TEXT_LIMIT)}…`
|
|
191
|
+
truncated = true
|
|
192
|
+
}
|
|
193
|
+
if (textBudget + body.length > DETAIL_TOTAL_TEXT_LIMIT) {
|
|
194
|
+
truncated = true
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
textBudget += body.length
|
|
198
|
+
items.push({
|
|
199
|
+
seq: Number.isInteger(document?.seq) ? document.seq : items.length,
|
|
200
|
+
role: document?.type === 'user/message' ? 'user' : 'assistant',
|
|
201
|
+
time: typeof document?.time === 'number' ? document.time : null,
|
|
202
|
+
text: body,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { items, total: found.length, shown: items.length, truncated: truncated || items.length < found.length }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sendJson = (res, status, payload) => {
|
|
210
|
+
res.writeHead(status, {
|
|
211
|
+
'content-type': 'application/json; charset=utf-8',
|
|
212
|
+
'cache-control': 'no-store',
|
|
213
|
+
'x-content-type-options': 'nosniff',
|
|
214
|
+
})
|
|
215
|
+
res.end(JSON.stringify(payload))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const failure = (code, message) => ({ ok: false, error: { code, message } })
|
|
219
|
+
|
|
220
|
+
const readJsonBody = async req => {
|
|
221
|
+
const chunks = []
|
|
222
|
+
let size = 0
|
|
223
|
+
for await (const chunk of req) {
|
|
224
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
225
|
+
size += buffer.length
|
|
226
|
+
if (size > MAX_REQUEST_BYTES) throw new Error('request-too-large')
|
|
227
|
+
chunks.push(buffer)
|
|
228
|
+
}
|
|
229
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const acceptJsonPost = (req, res) => {
|
|
233
|
+
if (req.method !== 'POST') {
|
|
234
|
+
sendJson(res, 405, failure('method-not-allowed', '只允许使用 POST 管理归档会话。'))
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
const host = req.headers.host
|
|
238
|
+
if (typeof host !== 'string' || req.headers.origin !== `http://${host}`) {
|
|
239
|
+
sendJson(res, 403, failure('forbidden', '归档管理请求未通过同源校验。'))
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
const contentType = req.headers['content-type']
|
|
243
|
+
if (typeof contentType !== 'string' || !contentType.toLowerCase().startsWith('application/json')) {
|
|
244
|
+
sendJson(res, 415, failure('unsupported-media-type', '归档管理请求必须使用 JSON。'))
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const parseBody = async (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
return await readJsonBody(req)
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const tooLarge = error instanceof Error && error.message === 'request-too-large'
|
|
255
|
+
sendJson(
|
|
256
|
+
res,
|
|
257
|
+
tooLarge ? 413 : 400,
|
|
258
|
+
failure(tooLarge ? 'request-too-large' : 'invalid-json', tooLarge ? '归档管理请求过大。' : '归档管理请求不是有效 JSON。'),
|
|
259
|
+
)
|
|
260
|
+
return undefined
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Same-origin HTTP boundary consumed by the native archive manager UI. */
|
|
265
|
+
export function createArchiveRequestHandlers({ restore, search, detail, warn = console.warn }) {
|
|
266
|
+
return {
|
|
267
|
+
restore: async (req, res) => {
|
|
268
|
+
if (!acceptJsonPost(req, res)) return
|
|
269
|
+
if (req.headers['x-dsh-session-manager-action'] !== 'restore-session') {
|
|
270
|
+
sendJson(res, 403, failure('forbidden', '恢复请求缺少明确的操作标记。'))
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
const body = await parseBody(req, res)
|
|
274
|
+
if (body === undefined) return
|
|
275
|
+
try {
|
|
276
|
+
const sessionId = assertSessionId(body?.sessionId)
|
|
277
|
+
sendJson(res, 200, { ok: true, value: await restore(sessionId) })
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof TypeError) {
|
|
280
|
+
sendJson(res, 400, failure('invalid-session-id', '会话 ID 无效。'))
|
|
281
|
+
} else {
|
|
282
|
+
sendJson(res, 500, failure('restore-failed', '取消归档失败,归档状态未确认改变。'))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
detail: async (req, res) => {
|
|
287
|
+
if (!acceptJsonPost(req, res)) return
|
|
288
|
+
const body = await parseBody(req, res)
|
|
289
|
+
if (body === undefined) return
|
|
290
|
+
try {
|
|
291
|
+
const sessionId = assertSessionId(body?.sessionId)
|
|
292
|
+
sendJson(res, 200, { ok: true, value: await detail(sessionId) })
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error instanceof TypeError) {
|
|
295
|
+
sendJson(res, 400, failure('invalid-session-id', '会话 ID 无效或不在归档集合中。'))
|
|
296
|
+
} else {
|
|
297
|
+
warn('archived history read failed:', error)
|
|
298
|
+
sendJson(res, 503, failure('detail-unavailable', '归档对话内容暂不可用。'))
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
search: async (req, res) => {
|
|
303
|
+
if (!acceptJsonPost(req, res)) return
|
|
304
|
+
const body = await parseBody(req, res)
|
|
305
|
+
if (body === undefined) return
|
|
306
|
+
const query = typeof body?.query === 'string' ? body.query.trim() : ''
|
|
307
|
+
if (query.length === 0 || query.length > 500 || query.includes('\0')) {
|
|
308
|
+
sendJson(res, 400, failure('invalid-query', '搜索内容无效。'))
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
const controller = new AbortController()
|
|
312
|
+
const abort = () => controller.abort(new Error('archive search request closed'))
|
|
313
|
+
req.once('aborted', abort)
|
|
314
|
+
if (typeof res.once === 'function') res.once('close', abort)
|
|
315
|
+
try {
|
|
316
|
+
const value = await search(query, controller.signal)
|
|
317
|
+
if (controller.signal.aborted) return
|
|
318
|
+
sendJson(res, 200, { ok: true, value })
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (controller.signal.aborted) return
|
|
321
|
+
warn('archived history search failed:', error)
|
|
322
|
+
sendJson(res, 503, failure('search-unavailable', '归档内容搜索暂不可用。'))
|
|
323
|
+
} finally {
|
|
324
|
+
req.off('aborted', abort)
|
|
325
|
+
if (typeof res.off === 'function') res.off('close', abort)
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
}
|