dsh-sessions-manager 3.2.2 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +6 -1
- package/README.md +6 -1
- package/lib/client.js +99 -5
- package/lib/client.js.map +2 -2
- package/lib/index.js +299 -24
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +90 -6
- package/src/client/logic.js +6 -0
- package/src/index.js +77 -0
- package/src/markdown.js +175 -0
- package/src/star-index.js +109 -0
package/src/client/index.jsx
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
-
import { canDropOnWorkspace, dotStateFor, sessionForNodes, workspaceForNodes } from './logic.js'
|
|
14
|
+
import { canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
|
|
15
15
|
|
|
16
16
|
export const inject = ['slots']
|
|
17
17
|
|
|
@@ -55,6 +55,11 @@ const CSS = `
|
|
|
55
55
|
.archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
|
|
56
56
|
.archv-dot{color:var(--dsw-alias-border-l3);flex:none}
|
|
57
57
|
.archv-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);flex:none;cursor:pointer}
|
|
58
|
+
.archv-star{appearance:none;width:24px;height:24px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:none;background:0 0;border-radius:7px;cursor:pointer;color:var(--dsw-alias-label-tertiary);transition:color .15s ease,background-color .15s ease}
|
|
59
|
+
.archv-star:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
60
|
+
.archv-star svg{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:round}
|
|
61
|
+
.archv-star-on,.archv-star-on:hover{color:var(--dsw-alias-state-business-primary)}
|
|
62
|
+
.archv-star-on svg{fill:currentColor}
|
|
58
63
|
.archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
|
|
59
64
|
.archv-actions{display:flex;gap:8px;flex:none;flex-wrap:nowrap;justify-content:flex-end}
|
|
60
65
|
.archv-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:9px;font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:6px;text-align:center;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
|
|
@@ -100,6 +105,9 @@ const CSS = `
|
|
|
100
105
|
.dtl-k{font-size:11px;color:var(--dsw-alias-label-tertiary)}
|
|
101
106
|
.dtl-v{font-size:12px;color:var(--dsw-alias-label-primary);word-break:break-all}
|
|
102
107
|
.dtl-sec{margin-top:12px}
|
|
108
|
+
.dtl-export{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
109
|
+
.dtl-export .archv-btn{text-decoration:none}
|
|
110
|
+
.dtl-note{margin-top:8px;font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5}
|
|
103
111
|
.dtl-sec-t{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px}
|
|
104
112
|
.dtl-tags{display:flex;flex-wrap:wrap;gap:6px}
|
|
105
113
|
.dtl-tag{display:inline-flex;font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);padding:2px 8px}
|
|
@@ -212,7 +220,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
212
220
|
const [sessions, setSessions] = useState(null)
|
|
213
221
|
const [workspaces, setWorkspaces] = useState([])
|
|
214
222
|
const initialPrefs = useRef(loadPanelPrefs()).current
|
|
215
|
-
const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
|
|
223
|
+
const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'starred', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
|
|
216
224
|
const [query, setQuery] = useState('')
|
|
217
225
|
const [workspaceFilter, setWorkspaceFilter] = useState(() => initialPrefs.workspaceFilter || 'all')
|
|
218
226
|
const [sortBy, setSortBy] = useState(() => ['newest', 'oldest', 'title'].includes(initialPrefs.sortBy) ? initialPrefs.sortBy : 'newest')
|
|
@@ -235,6 +243,9 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
235
243
|
const [details, setDetails] = useState({})
|
|
236
244
|
const [openDetails, setOpenDetails] = useState(null)
|
|
237
245
|
const [detailsLoading, setDetailsLoading] = useState(null)
|
|
246
|
+
const [mdBusy, setMdBusy] = useState(null)
|
|
247
|
+
const [zipOk, setZipOk] = useState(true)
|
|
248
|
+
const zipChecked = useRef(false)
|
|
238
249
|
const [openMenu, setOpenMenu] = useState(null)
|
|
239
250
|
const timer = useRef(null)
|
|
240
251
|
const menuRef = useRef(null)
|
|
@@ -278,6 +289,53 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
278
289
|
try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy })) } catch (e) {}
|
|
279
290
|
}, [filter, workspaceFilter, sortBy])
|
|
280
291
|
|
|
292
|
+
// 收藏切换:乐观更新 + 失败回滚(star 是高频轻操作,不等网络往返)。
|
|
293
|
+
const toggleStar = async (it) => {
|
|
294
|
+
const next = !it.starred
|
|
295
|
+
setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, starred: next } : x)))
|
|
296
|
+
try {
|
|
297
|
+
await postJSON('/archived-sessions/star/set', { sessionId: it.sessionId, starred: next })
|
|
298
|
+
} catch (e) {
|
|
299
|
+
setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, starred: !next } : x)))
|
|
300
|
+
showToast('收藏失败:' + String((e && e.message) || e))
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Markdown 导出:自有路由(/archived-sessions/export-md),blob 触发下载。
|
|
305
|
+
const exportMarkdown = async (it) => {
|
|
306
|
+
if (mdBusy) return
|
|
307
|
+
setMdBusy(it.sessionId)
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch('/archived-sessions/export-md?sessionId=' + encodeURIComponent(it.sessionId))
|
|
310
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
311
|
+
const blob = await res.blob()
|
|
312
|
+
const url = URL.createObjectURL(blob)
|
|
313
|
+
const a = document.createElement('a')
|
|
314
|
+
a.href = url
|
|
315
|
+
a.download = 'dsh-session-' + it.sessionId + '.md'
|
|
316
|
+
document.body.appendChild(a)
|
|
317
|
+
a.click()
|
|
318
|
+
a.remove()
|
|
319
|
+
URL.revokeObjectURL(url)
|
|
320
|
+
showToast('已导出 Markdown')
|
|
321
|
+
} catch (e) {
|
|
322
|
+
showToast('导出失败:' + String((e && e.message) || e))
|
|
323
|
+
} finally {
|
|
324
|
+
setMdBusy(null)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 官方 ZIP 导出预检(一次性):后端不支持 raw artifacts 时返回 501,
|
|
329
|
+
// 此时隐藏 ZIP 入口只留 Markdown(全局缓存,后端能力不会中途变)。
|
|
330
|
+
// 探针用非法 id 走 HEAD:命中 501 = 不支持;404/400 = 路由活着且支持。
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (openDetails === null || zipChecked.current) return
|
|
333
|
+
zipChecked.current = true
|
|
334
|
+
fetch('/api/session.export?sessionId=probe&includeDescendants=false', { method: 'HEAD' })
|
|
335
|
+
.then((res) => setZipOk(res.status !== 501))
|
|
336
|
+
.catch(() => setZipOk(true))
|
|
337
|
+
}, [openDetails])
|
|
338
|
+
|
|
281
339
|
// Close the ⋯ menu on outside click / Escape (no full-screen backdrop).
|
|
282
340
|
useEffect(() => {
|
|
283
341
|
if (openMenu === null) return
|
|
@@ -314,9 +372,10 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
314
372
|
|
|
315
373
|
const archivedList = sessions ? sessions.filter((x) => x.archived) : []
|
|
316
374
|
const activeList = sessions ? sessions.filter((x) => !x.archived) : []
|
|
375
|
+
const starredList = starredOf(sessions)
|
|
317
376
|
const list = useMemo(() => {
|
|
318
377
|
if (filter === 'trash') return []
|
|
319
|
-
const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : sessions || []
|
|
378
|
+
const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : filter === 'starred' ? starredList : sessions || []
|
|
320
379
|
const needle = query.trim().toLocaleLowerCase()
|
|
321
380
|
const filtered = base.filter((item) => {
|
|
322
381
|
if (workspaceFilter !== 'all' && (item.workspacePath || '') !== workspaceFilter) return false
|
|
@@ -569,6 +628,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
569
628
|
<button type="button" role="tab" aria-selected={filter === 'all'} className={'sess-fbtn' + (filter === 'all' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('all'); clearSel(); setConfirmBatch(false) }}>全部 ({sessions.length})</button>
|
|
570
629
|
<button type="button" role="tab" aria-selected={filter === 'active'} className={'sess-fbtn' + (filter === 'active' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('active'); clearSel(); setConfirmBatch(false) }}>活动 ({activeList.length})</button>
|
|
571
630
|
<button type="button" role="tab" aria-selected={filter === 'archived'} className={'sess-fbtn' + (filter === 'archived' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('archived'); clearSel(); setConfirmBatch(false) }}>已归档 ({archivedList.length})</button>
|
|
631
|
+
<button type="button" role="tab" aria-selected={filter === 'starred'} className={'sess-fbtn' + (filter === 'starred' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('starred'); clearSel(); setConfirmBatch(false) }}>已收藏 ({starredList.length})</button>
|
|
572
632
|
<button type="button" role="tab" aria-selected={filter === 'trash'} className={'sess-fbtn' + (filter === 'trash' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('trash'); clearSel(); setConfirmBatch(false) }}>回收站 ({trash.length})</button>
|
|
573
633
|
</div>
|
|
574
634
|
|
|
@@ -593,13 +653,13 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
593
653
|
</select>
|
|
594
654
|
</div>
|
|
595
655
|
</div>
|
|
596
|
-
<div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : sessions.length} 个` : ''}</div>
|
|
656
|
+
<div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : sessions.length} 个` : ''}</div>
|
|
597
657
|
</>
|
|
598
658
|
)}
|
|
599
659
|
|
|
600
660
|
{filter !== 'trash' && list.length > 0 && (
|
|
601
661
|
<div className="sess-batch">
|
|
602
|
-
<span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
|
|
662
|
+
<span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : filter === 'starred' ? `共 ${starredList.length} 个收藏会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
|
|
603
663
|
<button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
|
|
604
664
|
{selIds.length > 0 && (
|
|
605
665
|
<>
|
|
@@ -619,7 +679,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
619
679
|
)}
|
|
620
680
|
|
|
621
681
|
{filter !== 'trash' && list.length === 0 ? (
|
|
622
|
-
<div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : '暂无可管理的会话。'}</div>
|
|
682
|
+
<div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : '暂无可管理的会话。'}</div>
|
|
623
683
|
) : filter !== 'trash' ? (
|
|
624
684
|
<div className="archv-list" role="list">
|
|
625
685
|
{list.map((it) => {
|
|
@@ -635,6 +695,16 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
635
695
|
onChange={() => toggle(it.sessionId)}
|
|
636
696
|
aria-label={'选择 ' + (it.title || it.sessionId)}
|
|
637
697
|
/>
|
|
698
|
+
<button
|
|
699
|
+
type="button"
|
|
700
|
+
className={'archv-star' + (it.starred ? ' archv-star-on' : '')}
|
|
701
|
+
aria-pressed={!!it.starred}
|
|
702
|
+
aria-label={(it.starred ? '取消收藏 ' : '收藏 ') + (it.title || it.sessionId)}
|
|
703
|
+
title={it.starred ? '取消收藏' : '收藏'}
|
|
704
|
+
onClick={(e) => { e.stopPropagation(); toggleStar(it) }}
|
|
705
|
+
>
|
|
706
|
+
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false"><path d="M12 2.5l2.9 5.9 6.6.9-4.8 4.6 1.2 6.5-5.9-3.1-5.9 3.1 1.2-6.5L2.5 9.3l6.6-.9z" /></svg>
|
|
707
|
+
</button>
|
|
638
708
|
<div className="archv-body">
|
|
639
709
|
<div className="archv-main">
|
|
640
710
|
<div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
|
|
@@ -746,6 +816,20 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
746
816
|
</div>
|
|
747
817
|
</div>
|
|
748
818
|
)}
|
|
819
|
+
<div className="dtl-sec">
|
|
820
|
+
<div className="dtl-sec-t">导出</div>
|
|
821
|
+
<div className="dtl-export">
|
|
822
|
+
<a
|
|
823
|
+
className="archv-btn"
|
|
824
|
+
href={`/api/session.export?sessionId=${encodeURIComponent(it.sessionId)}&includeDescendants=true`}
|
|
825
|
+
onClick={(e) => e.stopPropagation()}
|
|
826
|
+
style={zipOk ? undefined : { pointerEvents: 'none', opacity: 0.45 }}
|
|
827
|
+
title={zipOk ? '含子会话与附件,由 DSH 提供' : '当前持久化后端不支持原始日志导出'}
|
|
828
|
+
>下载原始日志 (ZIP)</a>
|
|
829
|
+
<button type="button" className="archv-btn" disabled={mdBusy === it.sessionId} onClick={() => exportMarkdown(it)}>{mdBusy === it.sessionId ? '生成中…' : '导出 Markdown'}</button>
|
|
830
|
+
</div>
|
|
831
|
+
<div className="dtl-note">ZIP 含子会话与附件,由 DSH 提供 · Markdown 为本插件生成的可读对话记录</div>
|
|
832
|
+
</div>
|
|
749
833
|
</div>
|
|
750
834
|
)
|
|
751
835
|
})()}
|
package/src/client/logic.js
CHANGED
|
@@ -51,3 +51,9 @@ export function workspaceForNodes(nodes) {
|
|
|
51
51
|
}
|
|
52
52
|
return null
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// 收藏过滤:返回已收藏子集。star 是用户标记,与 DSH 的活动/归档状态正交
|
|
56
|
+
// (可叠加),所以这里不做任何状态联合判断,只认 starred 字段。
|
|
57
|
+
export function starredOf(items) {
|
|
58
|
+
return (items || []).filter((item) => item && item.starred)
|
|
59
|
+
}
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { rewriteFrame0Cwd } from './zstd-frame.js'
|
|
15
|
+
import { renderSessionMarkdown } from './markdown.js'
|
|
16
|
+
import { createStarIndex } from './star-index.js'
|
|
15
17
|
|
|
16
18
|
export const name = 'dsh-sessions-manager'
|
|
17
19
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
@@ -226,6 +228,21 @@ export function apply(ctx) {
|
|
|
226
228
|
return operation
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
// ---- Starred sessions (收藏, schema v3) -----------------------------------
|
|
232
|
+
// User marks, kept in the plugin's own index (never touches DSH logs). Stars
|
|
233
|
+
// survive archive & soft-delete — both are reversible — and are dropped only
|
|
234
|
+
// when the session is really gone (purge, or externally removed; the latter
|
|
235
|
+
// is caught by gcStars during list builds).
|
|
236
|
+
const stars = createStarIndex()
|
|
237
|
+
async function gcStars(validIds) {
|
|
238
|
+
try {
|
|
239
|
+
const store = await stars.read()
|
|
240
|
+
const valid = new Set(validIds.map(String))
|
|
241
|
+
const gone = store.starredSessionIds.filter((id) => !valid.has(id))
|
|
242
|
+
if (gone.length) await stars.removeIds(gone)
|
|
243
|
+
} catch (e) { /* best-effort */ }
|
|
244
|
+
}
|
|
245
|
+
|
|
229
246
|
// Soft-delete one session: record it in the recycle-bin index but KEEP its
|
|
230
247
|
// log in the original workspace directory. Moving the file out (and detaching
|
|
231
248
|
// it from the workspace) orphaned the session into DSH's "未分组" group and
|
|
@@ -364,6 +381,7 @@ export function apply(ctx) {
|
|
|
364
381
|
purged = true
|
|
365
382
|
})
|
|
366
383
|
if (!purged) throw new Error('彻底删除失败')
|
|
384
|
+
stars.removeIds([sid]).catch(() => {})
|
|
367
385
|
return { ok: true, purged: true }
|
|
368
386
|
}
|
|
369
387
|
|
|
@@ -644,9 +662,11 @@ export function apply(ctx) {
|
|
|
644
662
|
async function allSessionItems() {
|
|
645
663
|
let materialized = new Set()
|
|
646
664
|
let live = ctx.get('sessions')
|
|
665
|
+
let headersOk = false
|
|
647
666
|
try {
|
|
648
667
|
const headers = await sp.list()
|
|
649
668
|
materialized = new Set(headers.map((h) => String(h.id)))
|
|
669
|
+
headersOk = true
|
|
650
670
|
} catch (e) { /* best-effort */ }
|
|
651
671
|
const ids = []
|
|
652
672
|
try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) { /* ignore */ }
|
|
@@ -668,6 +688,12 @@ export function apply(ctx) {
|
|
|
668
688
|
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map(resolveOne))
|
|
669
689
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
670
690
|
}
|
|
691
|
+
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
692
|
+
// failing sp.list() can never wipe the whole index.
|
|
693
|
+
let starredSet = new Set()
|
|
694
|
+
try { starredSet = new Set((await stars.read()).starredSessionIds) } catch (e) {}
|
|
695
|
+
for (const it of items) it.starred = starredSet.has(String(it.sessionId))
|
|
696
|
+
if (headersOk) await gcStars(ids)
|
|
671
697
|
return items
|
|
672
698
|
}
|
|
673
699
|
|
|
@@ -1049,6 +1075,57 @@ export function apply(ctx) {
|
|
|
1049
1075
|
},
|
|
1050
1076
|
}))
|
|
1051
1077
|
|
|
1078
|
+
// Star / unstar one or many sessions (收藏, schema v3).
|
|
1079
|
+
disposers.push(ctx.webServer.register({
|
|
1080
|
+
kind: 'exact',
|
|
1081
|
+
path: '/archived-sessions/star/set',
|
|
1082
|
+
handler: async (req, res) => {
|
|
1083
|
+
try {
|
|
1084
|
+
const body = await readJsonBody(req)
|
|
1085
|
+
const starred = !!(body && body.starred)
|
|
1086
|
+
let ids = parseIds(body)
|
|
1087
|
+
if ((!ids || ids.length === 0) && body && typeof body.sessionId === 'string') {
|
|
1088
|
+
ids = isSafeSessionId(body.sessionId) ? [body.sessionId] : null
|
|
1089
|
+
}
|
|
1090
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1091
|
+
const starredSessionIds = await stars.setStarred(ids, starred)
|
|
1092
|
+
json(res, { ok: true, starredSessionIds })
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
}))
|
|
1098
|
+
|
|
1099
|
+
// Human-readable Markdown export (one session). Raw-log ZIP export is
|
|
1100
|
+
// dsh's own GET /api/session.export — we deliberately do not duplicate it
|
|
1101
|
+
// (see reports/HANDOFF-dsh-sessions-manager-roadmap.md §2.4).
|
|
1102
|
+
disposers.push(ctx.webServer.register({
|
|
1103
|
+
kind: 'exact',
|
|
1104
|
+
path: '/archived-sessions/export-md',
|
|
1105
|
+
handler: async (req, res) => {
|
|
1106
|
+
try {
|
|
1107
|
+
const url = new URL(req.url, 'http://localhost')
|
|
1108
|
+
const sid = url.searchParams.get('sessionId')
|
|
1109
|
+
requireSessionId(sid)
|
|
1110
|
+
const r = await sp.readFrom(sid, 0)
|
|
1111
|
+
if (!r || !r.meta) {
|
|
1112
|
+
const error = new Error('无法读取该会话的日志')
|
|
1113
|
+
error.status = 404
|
|
1114
|
+
throw error
|
|
1115
|
+
}
|
|
1116
|
+
const md = renderSessionMarkdown({ ...r.meta, id: sid }, r.events || [])
|
|
1117
|
+
res.writeHead(200, {
|
|
1118
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
1119
|
+
'content-disposition': `attachment; filename="dsh-session-${sid}.md"`,
|
|
1120
|
+
'cache-control': 'no-store',
|
|
1121
|
+
})
|
|
1122
|
+
res.end(md)
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
json(res, { error: String((e && e.message) || e) }, errorStatus(e))
|
|
1125
|
+
}
|
|
1126
|
+
},
|
|
1127
|
+
}))
|
|
1128
|
+
|
|
1052
1129
|
disposers.push(ctx.webServer.register({
|
|
1053
1130
|
kind: 'exact',
|
|
1054
1131
|
path: '/archived-sessions/sidebar-state',
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Render a session log as human-readable Markdown.
|
|
2
|
+
//
|
|
3
|
+
// Pure: no DOM, no I/O, no dsh imports. Everything it needs arrives as
|
|
4
|
+
// arguments, so the renderer is unit-testable without a running host.
|
|
5
|
+
//
|
|
6
|
+
// Field shapes below were read off real session logs (2026-09-01), not guessed:
|
|
7
|
+
// user/message data.content = [{ type:'text', text } | { type:'image', ... }]
|
|
8
|
+
// assistant/message data.message.content = [{ type:'text'|'reasoning'|'tool-call', ... }]
|
|
9
|
+
// tool/call data.{name, arguments} (arguments is a JSON *string*)
|
|
10
|
+
// tool/result data.message.content = [{ type:'tool-result', content:[{type:'text',text}] }]
|
|
11
|
+
// Note the asymmetry: user text lives at data.content, assistant text one level
|
|
12
|
+
// deeper at data.message.content. Streaming deltas (`assistant/chunk`,
|
|
13
|
+
// `text-chunks`, `reasoning-chunks`) are never rendered — `assistant/message`
|
|
14
|
+
// already carries the final text for each step.
|
|
15
|
+
|
|
16
|
+
const MAX_TOOL_ARG = 200
|
|
17
|
+
|
|
18
|
+
function isoTime(value) {
|
|
19
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
|
|
20
|
+
try { return new Date(value).toISOString() } catch { return null }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function yamlString(value) {
|
|
24
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function blocksOf(value) {
|
|
28
|
+
return Array.isArray(value) ? value.filter((b) => b && typeof b === 'object') : []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Join the text blocks of a content array; image blocks are counted separately.
|
|
32
|
+
function textFromBlocks(blocks) {
|
|
33
|
+
const parts = []
|
|
34
|
+
for (const block of blocks) {
|
|
35
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
36
|
+
}
|
|
37
|
+
return parts.join('\n\n').trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function imageCountOf(blocks) {
|
|
41
|
+
let count = 0
|
|
42
|
+
for (const block of blocks) if (block.type === 'image') count++
|
|
43
|
+
return count
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reasoningFromBlocks(blocks) {
|
|
47
|
+
const parts = []
|
|
48
|
+
for (const block of blocks) {
|
|
49
|
+
if (block.type === 'reasoning' && typeof block.text === 'string' && block.text.trim()) parts.push(block.text.trim())
|
|
50
|
+
}
|
|
51
|
+
return parts.join('\n\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A short, human-usable summary of one tool call's arguments.
|
|
55
|
+
export function summarizeToolArguments(name, rawArguments) {
|
|
56
|
+
let parsed = null
|
|
57
|
+
if (typeof rawArguments === 'string') {
|
|
58
|
+
try { parsed = JSON.parse(rawArguments) } catch { parsed = null }
|
|
59
|
+
} else if (rawArguments && typeof rawArguments === 'object') {
|
|
60
|
+
parsed = rawArguments
|
|
61
|
+
}
|
|
62
|
+
if (parsed === null) return typeof rawArguments === 'string' ? rawArguments.slice(0, MAX_TOOL_ARG) : ''
|
|
63
|
+
if (typeof parsed !== 'object') return String(parsed).slice(0, MAX_TOOL_ARG)
|
|
64
|
+
const preferred = ['command', 'file_path', 'path', 'query', 'url', 'pattern']
|
|
65
|
+
for (const key of preferred) {
|
|
66
|
+
if (typeof parsed[key] === 'string' && parsed[key].trim()) return parsed[key]
|
|
67
|
+
}
|
|
68
|
+
const keys = Object.keys(parsed)
|
|
69
|
+
if (keys.length === 0) return ''
|
|
70
|
+
const rest = {}
|
|
71
|
+
for (const key of keys.slice(0, 6)) {
|
|
72
|
+
const value = parsed[key]
|
|
73
|
+
rest[key] = typeof value === 'string' ? value : JSON.stringify(value)
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify(rest).slice(0, MAX_TOOL_ARG)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render one session as Markdown.
|
|
80
|
+
* @param {object} meta - Session header (`{ id, cwd, createdAt, title? }`).
|
|
81
|
+
* @param {Array<object>} events - Session events as stored in the log.
|
|
82
|
+
* @param {object} [options]
|
|
83
|
+
* @param {boolean} [options.includeReasoning=false] - Emit assistant reasoning blocks.
|
|
84
|
+
* @param {boolean} [options.includeToolResults=false] - Emit tool results.
|
|
85
|
+
* @param {number} [options.exportedAt] - Override the export timestamp (tests).
|
|
86
|
+
* @returns {string} Markdown document.
|
|
87
|
+
*/
|
|
88
|
+
export function renderSessionMarkdown(meta, events, options = {}) {
|
|
89
|
+
const includeReasoning = options.includeReasoning === true
|
|
90
|
+
const includeToolResults = options.includeToolResults === true
|
|
91
|
+
const header = meta && typeof meta === 'object' ? meta : {}
|
|
92
|
+
const list = Array.isArray(events) ? events : []
|
|
93
|
+
|
|
94
|
+
// The last session/title event wins — DSH may retitle a session later on.
|
|
95
|
+
let title = typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null
|
|
96
|
+
for (const ev of list) {
|
|
97
|
+
const data = ev && ev.data
|
|
98
|
+
if (ev && ev.type === 'session/title' && data && typeof data.title === 'string' && data.title.trim()) {
|
|
99
|
+
title = data.title.trim()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const front = ['---']
|
|
104
|
+
if (title) front.push(`title: ${yamlString(title)}`)
|
|
105
|
+
if (typeof header.id === 'string' && header.id) front.push(`sessionId: ${yamlString(header.id)}`)
|
|
106
|
+
if (typeof header.cwd === 'string' && header.cwd) front.push(`cwd: ${yamlString(header.cwd)}`)
|
|
107
|
+
const created = isoTime(header.createdAt)
|
|
108
|
+
if (created) front.push(`createdAt: ${created}`)
|
|
109
|
+
const exported = isoTime(options.exportedAt)
|
|
110
|
+
if (exported) front.push(`exportedAt: ${exported}`)
|
|
111
|
+
front.push('---')
|
|
112
|
+
|
|
113
|
+
const out = [front.join('\n')]
|
|
114
|
+
if (title) out.push('', `# ${title}`)
|
|
115
|
+
|
|
116
|
+
let turn = null
|
|
117
|
+
for (const ev of list) {
|
|
118
|
+
if (!ev || typeof ev !== 'object') continue
|
|
119
|
+
const data = ev.data && typeof ev.data === 'object' ? ev.data : {}
|
|
120
|
+
const type = ev.type
|
|
121
|
+
|
|
122
|
+
if (type === 'turn/start') {
|
|
123
|
+
const next = Number.isInteger(data.turn) ? data.turn : null
|
|
124
|
+
if (next !== null && next !== turn) {
|
|
125
|
+
turn = next
|
|
126
|
+
out.push('', `## 第 ${turn} 轮`)
|
|
127
|
+
}
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (type === 'user/message') {
|
|
132
|
+
const blocks = blocksOf(data.content)
|
|
133
|
+
const text = textFromBlocks(blocks)
|
|
134
|
+
const images = imageCountOf(blocks)
|
|
135
|
+
if (!text && images === 0) continue
|
|
136
|
+
out.push('', '### 用户', '')
|
|
137
|
+
if (text) out.push(text)
|
|
138
|
+
for (let i = 0; i < images; i++) out.push('', ``)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (type === 'assistant/message') {
|
|
143
|
+
const message = data.message && typeof data.message === 'object' ? data.message : {}
|
|
144
|
+
const blocks = blocksOf(message.content)
|
|
145
|
+
const text = textFromBlocks(blocks)
|
|
146
|
+
const reasoning = includeReasoning ? reasoningFromBlocks(blocks) : ''
|
|
147
|
+
if (!text && !reasoning) continue
|
|
148
|
+
out.push('', '### 助手', '')
|
|
149
|
+
if (reasoning) out.push('> 思考:' + reasoning.split('\n').join('\n> '), '')
|
|
150
|
+
if (text) out.push(text)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (type === 'tool/call') {
|
|
155
|
+
const name = typeof data.name === 'string' && data.name ? data.name : 'tool'
|
|
156
|
+
const summary = summarizeToolArguments(name, data.arguments)
|
|
157
|
+
out.push('', `### 工具调用:\`${name}\``, '')
|
|
158
|
+
out.push(summary ? '```\n' + summary + '\n```' : '(无参数)')
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (type === 'tool/result' && includeToolResults) {
|
|
163
|
+
const message = data.message && typeof data.message === 'object' ? data.message : {}
|
|
164
|
+
const blocks = blocksOf(message.content)
|
|
165
|
+
let text = ''
|
|
166
|
+
for (const block of blocks) {
|
|
167
|
+
if (block.type === 'tool-result') text = textFromBlocks(blocksOf(block.content))
|
|
168
|
+
}
|
|
169
|
+
if (text) out.push('', '<details><summary>工具结果</summary>', '', '```\n' + text.slice(0, 2000) + '\n```', '', '</details>')
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
out.push('')
|
|
174
|
+
return out.join('\n')
|
|
175
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Durable "starred sessions" index (schema v3).
|
|
2
|
+
//
|
|
3
|
+
// Deliberately mirrors the recycle-bin index in src/index.js: version field,
|
|
4
|
+
// automatic upgrade of older shapes, atomic write (tmp + rename) and a single
|
|
5
|
+
// chained mutation queue so two concurrent requests can never clobber each
|
|
6
|
+
// other. Extracted from the host bundle so it can be unit-tested directly —
|
|
7
|
+
// pass `dir` to point the index at a temp directory.
|
|
8
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
// v3 is the first star schema; it starts at 3 so it can never be confused with
|
|
14
|
+
// the recycle bin's v1/v2 documents even if a file is copied between them.
|
|
15
|
+
export const STAR_SCHEMA_VERSION = 3
|
|
16
|
+
|
|
17
|
+
const DEFAULT_STAR_DIR = join(homedir(), '.dsh', 'sessions-manager')
|
|
18
|
+
|
|
19
|
+
function isSafeSessionId(value) {
|
|
20
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\\/\0]/.test(value) && value !== '.' && value !== '..'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Coerce anything on disk (or nothing at all) into a valid v3 store.
|
|
25
|
+
* Accepts a bare array of ids (the pre-schema shape) and upgrades it.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeStarStore(raw) {
|
|
28
|
+
const legacy = Array.isArray(raw) ? raw : null
|
|
29
|
+
const source = legacy || (raw && typeof raw === 'object' ? raw : null)
|
|
30
|
+
const ids = source && Array.isArray(source.starredSessionIds) ? source.starredSessionIds : (legacy || [])
|
|
31
|
+
const clean = []
|
|
32
|
+
const seen = new Set()
|
|
33
|
+
for (const id of ids) {
|
|
34
|
+
// Strings only: silently coercing a number into an id would let junk into
|
|
35
|
+
// the index and mask a caller bug.
|
|
36
|
+
if (!isSafeSessionId(id)) continue
|
|
37
|
+
if (seen.has(id)) continue
|
|
38
|
+
seen.add(id)
|
|
39
|
+
clean.push(id)
|
|
40
|
+
}
|
|
41
|
+
return { schemaVersion: STAR_SCHEMA_VERSION, starredSessionIds: clean }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Open the star index.
|
|
46
|
+
* @param {object} [options]
|
|
47
|
+
* @param {string} [options.dir] - Directory holding the index (tests inject a temp dir).
|
|
48
|
+
* @param {string} [options.indexPath] - Full index path, overriding `dir`.
|
|
49
|
+
*/
|
|
50
|
+
export function createStarIndex(options = {}) {
|
|
51
|
+
const dir = options.dir || process.env.DSH_SESSIONS_MANAGER_STAR_DIR || DEFAULT_STAR_DIR
|
|
52
|
+
const indexPath = options.indexPath || join(dir, 'star.json')
|
|
53
|
+
let mutation = Promise.resolve()
|
|
54
|
+
|
|
55
|
+
async function read() {
|
|
56
|
+
try {
|
|
57
|
+
return normalizeStarStore(JSON.parse(readFileSync(indexPath, 'utf8')))
|
|
58
|
+
} catch {
|
|
59
|
+
return normalizeStarStore(null)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function write(store) {
|
|
64
|
+
await mkdir(dir, { recursive: true })
|
|
65
|
+
const tmp = join(dir, `.star-${process.pid}-${Date.now()}.tmp`)
|
|
66
|
+
await writeFile(tmp, JSON.stringify(normalizeStarStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
67
|
+
await rename(tmp, indexPath)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Serialize read-modify-write cycles: every mutator sees the store as left by
|
|
71
|
+
// the previous one, and a rejected mutator still keeps the chain alive.
|
|
72
|
+
function mutate(mutator) {
|
|
73
|
+
const operation = mutation.then(async () => {
|
|
74
|
+
const store = await read()
|
|
75
|
+
const result = await mutator(store)
|
|
76
|
+
await write(store)
|
|
77
|
+
return result
|
|
78
|
+
})
|
|
79
|
+
mutation = operation.catch(() => {})
|
|
80
|
+
return operation
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Star or unstar sessions.
|
|
85
|
+
* @param {string[]} ids - Session ids to change.
|
|
86
|
+
* @param {boolean} starred - true to star, false to unstar.
|
|
87
|
+
* @returns {Promise<string[]>} The full starred set after the change.
|
|
88
|
+
*/
|
|
89
|
+
function setStarred(ids, starred) {
|
|
90
|
+
const wanted = (Array.isArray(ids) ? ids : []).filter(isSafeSessionId).map(String)
|
|
91
|
+
return mutate((store) => {
|
|
92
|
+
const set = new Set(store.starredSessionIds)
|
|
93
|
+
for (const id of wanted) {
|
|
94
|
+
if (starred) set.add(id)
|
|
95
|
+
else set.delete(id)
|
|
96
|
+
}
|
|
97
|
+
store.starredSessionIds = [...set]
|
|
98
|
+
return store.starredSessionIds
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Drop ids once their session is gone (purged / deleted), otherwise the index
|
|
103
|
+
// would grow forever with ids that can never be listed again.
|
|
104
|
+
function removeIds(ids) {
|
|
105
|
+
return setStarred(ids, false)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { read, write, mutate, setStarred, removeIds, indexPath, dir }
|
|
109
|
+
}
|