@roaming-ai/dsh-group-chat 0.2.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.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +3308 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +2583 -0
  7. package/lib/types/client/Bubble.d.ts +10 -0
  8. package/lib/types/client/Glyph.d.ts +12 -0
  9. package/lib/types/client/GroupChatPanel.d.ts +10 -0
  10. package/lib/types/client/GroupChatPanel.old.d.ts +10 -0
  11. package/lib/types/client/GroupChatSettingsSection.d.ts +15 -0
  12. package/lib/types/client/PermissionSelect.d.ts +13 -0
  13. package/lib/types/client/RoleDrawer.d.ts +17 -0
  14. package/lib/types/client/ThinkRow.d.ts +9 -0
  15. package/lib/types/client/ToolRow.d.ts +9 -0
  16. package/lib/types/client/api.d.ts +8 -0
  17. package/lib/types/client/components/AsidePanel.d.ts +31 -0
  18. package/lib/types/client/components/Bubble.d.ts +19 -0
  19. package/lib/types/client/components/ChatPanel.d.ts +57 -0
  20. package/lib/types/client/components/Composer.d.ts +48 -0
  21. package/lib/types/client/components/Glyph.d.ts +12 -0
  22. package/lib/types/client/components/GroupChatSettingsSection.d.ts +15 -0
  23. package/lib/types/client/components/MessageFlow.d.ts +15 -0
  24. package/lib/types/client/components/NavPanel.d.ts +40 -0
  25. package/lib/types/client/components/PermissionSelect.d.ts +13 -0
  26. package/lib/types/client/components/RoleDrawer.d.ts +17 -0
  27. package/lib/types/client/components/ThinkRow.d.ts +9 -0
  28. package/lib/types/client/components/ToolRow.d.ts +9 -0
  29. package/lib/types/client/components/index.d.ts +9 -0
  30. package/lib/types/client/components.d.ts +22 -0
  31. package/lib/types/client/drawer.d.ts +17 -0
  32. package/lib/types/client/glyph.d.ts +12 -0
  33. package/lib/types/client/hooks/index.d.ts +6 -0
  34. package/lib/types/client/hooks/useComposer.d.ts +27 -0
  35. package/lib/types/client/hooks/useFileSearch.d.ts +19 -0
  36. package/lib/types/client/hooks/useGroupChatState.d.ts +95 -0
  37. package/lib/types/client/hooks/useMentionState.d.ts +27 -0
  38. package/lib/types/client/index.d.ts +52 -0
  39. package/lib/types/client/lib/api.d.ts +8 -0
  40. package/lib/types/client/lib/model.d.ts +80 -0
  41. package/lib/types/client/lib/styles.d.ts +10 -0
  42. package/lib/types/client/lib/ui.d.ts +17 -0
  43. package/lib/types/client/model.d.ts +78 -0
  44. package/lib/types/client/panel.d.ts +10 -0
  45. package/lib/types/client/settings.d.ts +15 -0
  46. package/lib/types/client/styles.d.ts +10 -0
  47. package/lib/types/client/ui.d.ts +17 -0
  48. package/lib/types/client/utils/utils.d.ts +29 -0
  49. package/lib/types/client/utils.d.ts +22 -0
  50. package/lib/types/core/json.d.ts +9 -0
  51. package/lib/types/core/status.d.ts +18 -0
  52. package/lib/types/core/tools.d.ts +59 -0
  53. package/lib/types/core/types.d.ts +256 -0
  54. package/lib/types/host/actions.d.ts +34 -0
  55. package/lib/types/host/api/actions.d.ts +36 -0
  56. package/lib/types/host/api/http.d.ts +16 -0
  57. package/lib/types/host/api/index.d.ts +7 -0
  58. package/lib/types/host/api/routes.d.ts +11 -0
  59. package/lib/types/host/broadcast.d.ts +20 -0
  60. package/lib/types/host/conversation.d.ts +25 -0
  61. package/lib/types/host/engine/conversation.d.ts +25 -0
  62. package/lib/types/host/engine/index.d.ts +7 -0
  63. package/lib/types/host/engine/retitle.d.ts +20 -0
  64. package/lib/types/host/http.d.ts +16 -0
  65. package/lib/types/host/index.d.ts +8 -0
  66. package/lib/types/host/materials/index.d.ts +6 -0
  67. package/lib/types/host/materials/materials.d.ts +40 -0
  68. package/lib/types/host/materials.d.ts +32 -0
  69. package/lib/types/host/persistence/index.d.ts +6 -0
  70. package/lib/types/host/persistence/persistence.d.ts +31 -0
  71. package/lib/types/host/persistence/store.d.ts +65 -0
  72. package/lib/types/host/persistence.d.ts +31 -0
  73. package/lib/types/host/routes.d.ts +11 -0
  74. package/lib/types/host/service.d.ts +28 -0
  75. package/lib/types/host/state.d.ts +45 -0
  76. package/lib/types/host/store.d.ts +65 -0
  77. package/lib/types/host/tools/index.d.ts +6 -0
  78. package/lib/types/host/tools/tools.d.ts +28 -0
  79. package/lib/types/host/tools.d.ts +28 -0
  80. package/lib/types/index.d.ts +41 -0
  81. package/lib/types/mount-once.d.ts +9 -0
  82. package/lib/types/shared/file-mention-grammar.d.ts +37 -0
  83. package/lib/types/shared/file-mention-grammar.test.d.ts +5 -0
  84. package/package.json +98 -0
  85. package/src/client/GroupChatPanel.tsx +385 -0
  86. package/src/client/components/AsidePanel.tsx +188 -0
  87. package/src/client/components/Bubble.tsx +76 -0
  88. package/src/client/components/ChatPanel.tsx +214 -0
  89. package/src/client/components/Composer.tsx +238 -0
  90. package/src/client/components/Glyph.tsx +27 -0
  91. package/src/client/components/GroupChatSettingsSection.tsx +74 -0
  92. package/src/client/components/MessageFlow.tsx +97 -0
  93. package/src/client/components/NavPanel.tsx +249 -0
  94. package/src/client/components/PermissionSelect.tsx +159 -0
  95. package/src/client/components/RoleDrawer.tsx +186 -0
  96. package/src/client/components/ThinkRow.tsx +32 -0
  97. package/src/client/components/ToolRow.tsx +51 -0
  98. package/src/client/components/index.ts +10 -0
  99. package/src/client/hooks/index.ts +7 -0
  100. package/src/client/hooks/useComposer.ts +272 -0
  101. package/src/client/hooks/useFileSearch.ts +115 -0
  102. package/src/client/hooks/useGroupChatState.ts +214 -0
  103. package/src/client/hooks/useMentionState.ts +56 -0
  104. package/src/client/index.ts +110 -0
  105. package/src/client/lib/api.ts +23 -0
  106. package/src/client/lib/model.ts +119 -0
  107. package/src/client/lib/styles.ts +317 -0
  108. package/src/client/lib/ui.ts +27 -0
  109. package/src/client/react-dom-shim.d.ts +13 -0
  110. package/src/client/utils/utils.ts +104 -0
  111. package/src/core/json.ts +28 -0
  112. package/src/core/status.ts +33 -0
  113. package/src/core/tools.ts +41 -0
  114. package/src/core/types.ts +222 -0
  115. package/src/host/api/actions.ts +335 -0
  116. package/src/host/api/http.ts +80 -0
  117. package/src/host/api/index.ts +8 -0
  118. package/src/host/api/routes.ts +95 -0
  119. package/src/host/broadcast.ts +65 -0
  120. package/src/host/engine/conversation.ts +331 -0
  121. package/src/host/engine/index.ts +8 -0
  122. package/src/host/engine/retitle.ts +135 -0
  123. package/src/host/index.ts +9 -0
  124. package/src/host/materials/index.ts +7 -0
  125. package/src/host/materials/materials.ts +193 -0
  126. package/src/host/persistence/index.ts +7 -0
  127. package/src/host/persistence/persistence.ts +311 -0
  128. package/src/host/persistence/store.ts +375 -0
  129. package/src/host/service.ts +78 -0
  130. package/src/host/state.ts +85 -0
  131. package/src/host/tools/index.ts +7 -0
  132. package/src/host/tools/tools.ts +194 -0
  133. package/src/index.ts +99 -0
  134. package/src/mount-once.ts +49 -0
  135. package/src/shared/file-mention-grammar.test.ts +77 -0
  136. package/src/shared/file-mention-grammar.ts +82 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * 资料读取与路径解析:群组工作区目录 → 注入文件清单 + 目录浏览器 + @ 文件检索。
3
+ * 路径解析顺序:~ 展开到 home;绝对路径直用;相对路径先试各工作区根,
4
+ * 再试 dsh web 进程 cwd。目录浏览器、资料读取与文件检索共用同一套解析。
5
+ * 无独立笔记/文件清单:共享资料 = workspaceDir 根下一层文本文件。
6
+ * @module dsh-group-chat/host/materials
7
+ */
8
+
9
+ import { homedir } from 'node:os'
10
+ import { dirname, isAbsolute, join } from 'node:path'
11
+ import {
12
+ DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
13
+ DEFAULT_FILE_SEARCH_MAX_ENTRIES,
14
+ DEFAULT_FILE_SEARCH_MAX_RESULTS,
15
+ WorkspaceFileSearch,
16
+ } from '@deepseek-ai/dsh-file-reference-local'
17
+ import type { BrowseResult, FileSearchResult, GroupRecord } from '../../core/types.ts'
18
+ import type { HostState } from '../state.ts'
19
+
20
+ // 群组工作区目录 → 注入文件清单:目录内文本文件(白名单扩展名、跳过隐藏项,
21
+ // 最多 20 个),单文件 16k、总量 48k 截断由 materialBlock 执行。
22
+ const TEXT_EXTS = new Set(['.md', '.markdown', '.txt', '.json', '.yml', '.yaml', '.csv', '.tsv', '.toml', '.ini', '.conf', '.env', '.properties', '.log', '.xml', '.html', '.css', '.ts', '.tsx', '.js', '.jsx', '.py', '.java', '.go', '.rs', '.c', '.h', '.cpp', '.sql', '.sh'])
23
+ const MAX_WS_FILES = 20
24
+
25
+ /** 资料面。 */
26
+ export interface Materials {
27
+ /** 群组工作区目录 → 注入文件清单(dir 为解析后的展示路径)。 */
28
+ loadWorkspaceFiles: (g: GroupRecord) => Promise<{ dir: string, parts: { name: string, content: string, error?: string }[] }>
29
+ /** 文件清单 → system 提示词内的「共享资料」块(单文件 16k / 总量 48k 截断)。 */
30
+ materialBlock: (parts: { name: string, content: string, error?: string }[], dir: string) => string
31
+ /** 目录浏览:返回文件+目录条目(绝对路径由 Host 解析,客户端不拼路径)。 */
32
+ browse: (args: { path?: string } | undefined) => Promise<BrowseResult>
33
+ /** 群工作区 @ 文件检索(WorkspaceFileSearch,根 = 解析后的 workspaceDir)。 */
34
+ fileSearch: (args: { groupId?: string, query?: string } | undefined) => Promise<FileSearchResult>
35
+ /** 释放某群(或全部)文件检索索引。 */
36
+ disposeFileSearch: (groupId?: string) => void
37
+ }
38
+
39
+ /** 创建资料面。 */
40
+ export function createMaterials(core: HostState): Materials {
41
+ const { ctx, fs } = core
42
+ const HOME = homedir()
43
+ let workspacePathsCache = { at: 0, paths: [] as string[] }
44
+ const workspacePaths = async (): Promise<string[]> => {
45
+ const now = Date.now()
46
+ if (now - workspacePathsCache.at < 5000) return workspacePathsCache.paths
47
+ try {
48
+ const list = await ctx.workspaceRegistry.list()
49
+ workspacePathsCache = { at: now, paths: list.map((w) => w.path).filter(Boolean) }
50
+ } catch {
51
+ workspacePathsCache = { at: now, paths: workspacePathsCache.paths }
52
+ }
53
+ return workspacePathsCache.paths
54
+ }
55
+
56
+ const candidatePaths = async (raw: unknown): Promise<string[]> => {
57
+ const p = String(raw || '').trim()
58
+ if (p === '' || p === '~') return [HOME]
59
+ if (p.startsWith('~/')) return [join(HOME, p.slice(2))]
60
+ if (isAbsolute(p)) return [p]
61
+ const roots = [...(await workspacePaths()), process.cwd()]
62
+ return roots.map((root) => join(root, p))
63
+ }
64
+
65
+ /** 逐候选 stat,返回第一个存在的目标;都不存在时返回首候选与全部尝试。 */
66
+ const resolveMaterialTarget = async (raw: unknown) => {
67
+ const candidates = await candidatePaths(raw)
68
+ let first: { target: import('@deepseek-ai/dsh-fs').FsTarget, path: string } | undefined
69
+ for (const c of candidates) {
70
+ const target = await fs.resolve(c)
71
+ if (first === undefined) first = { target, path: c }
72
+ const info = await fs.stat(target)
73
+ if (info !== undefined) return { target, path: c, info, tried: candidates }
74
+ }
75
+ return { target: first!.target, path: first!.path, info: undefined, tried: candidates }
76
+ }
77
+
78
+ const loadWorkspaceFiles = async (g: GroupRecord) => {
79
+ if (!g.workspaceDir) return { dir: '', parts: [] as { name: string, content: string, error?: string }[] }
80
+ const res = await resolveMaterialTarget(g.workspaceDir)
81
+ if (res.info === undefined) throw new Error('工作区目录不存在(尝试过:' + res.tried.join(';') + ')')
82
+ if (res.info.type !== 'directory') throw new Error('工作区目录不是目录:' + res.path)
83
+ const entries = await fs.listDir(res.target)
84
+ const parts: { name: string, content: string, error?: string }[] = []
85
+ for (const e of entries) {
86
+ if (parts.length >= MAX_WS_FILES) break
87
+ if (e.type !== 'file' || e.name.startsWith('.')) continue
88
+ const dot = e.name.lastIndexOf('.')
89
+ const ext = dot === -1 ? '' : e.name.slice(dot).toLowerCase()
90
+ if (!TEXT_EXTS.has(ext)) continue
91
+ try {
92
+ const text = await fs.readText(e.target)
93
+ parts.push({ name: e.name, content: text })
94
+ } catch (err) {
95
+ parts.push({ name: e.name, content: '', error: String((err && (err as Error).message) || err) })
96
+ }
97
+ }
98
+ return { dir: res.path, parts }
99
+ }
100
+
101
+ const materialBlock = (parts: { name: string, content: string, error?: string }[], dir: string): string => {
102
+ if (!parts.length) return ''
103
+ let total = 0
104
+ const lines: string[] = []
105
+ for (const p of parts) {
106
+ if (p.error) {
107
+ lines.push('### ' + p.name + '\n[读取失败] ' + p.error)
108
+ continue
109
+ }
110
+ let c = p.content || ''
111
+ if (c.length > 16000) c = c.slice(0, 16000) + '\n…(已截断)'
112
+ if (total + c.length > 48000) c = c.slice(0, Math.max(0, 48000 - total)) + '\n…(总量超限截断)'
113
+ total += c.length
114
+ lines.push('### ' + p.name + '\n' + c)
115
+ }
116
+ return '\n# 共享资料(来自群组工作区目录' + (dir ? ' ' + dir : '') + ',群内所有成员可见)\n' + lines.join('\n\n')
117
+ }
118
+
119
+ const browse = async (args: { path?: string } | undefined): Promise<BrowseResult> => {
120
+ try {
121
+ const raw = String((args && args.path) || '').trim()
122
+ const res = await resolveMaterialTarget(raw === '' ? HOME : raw)
123
+ if (res.info === undefined) return { ok: false, error: '路径不存在,尝试过:' + res.tried.join(';') }
124
+ let dirPath = res.path
125
+ let target = res.target
126
+ if (res.info.type !== 'directory') {
127
+ dirPath = dirname(dirPath)
128
+ target = await fs.resolve(dirPath)
129
+ }
130
+ const entries = await fs.listDir(target)
131
+ const sorted = [...entries].sort((a, b) => (a.type === 'directory' ? 0 : 1) - (b.type === 'directory' ? 0 : 1) || a.name.localeCompare(b.name))
132
+ return {
133
+ ok: true,
134
+ path: dirPath,
135
+ home: HOME,
136
+ parent: dirname(dirPath),
137
+ entries: sorted.map((e) => ({ name: e.name, type: e.type, path: fs.processPath(e.target), size: e.size, hidden: e.name.startsWith('.') })),
138
+ }
139
+ } catch (e) {
140
+ return { ok: false, error: String((e && (e as Error).message) || e) }
141
+ }
142
+ }
143
+
144
+ const FILE_SEARCH_CONFIG = {
145
+ maxResults: DEFAULT_FILE_SEARCH_MAX_RESULTS,
146
+ maxEntries: DEFAULT_FILE_SEARCH_MAX_ENTRIES,
147
+ excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES],
148
+ }
149
+ const searches = new Map<string, { root: string, search: WorkspaceFileSearch }>()
150
+
151
+ const disposeFileSearch = (groupId?: string): void => {
152
+ if (groupId) {
153
+ searches.get(groupId)?.search.dispose()
154
+ searches.delete(groupId)
155
+ return
156
+ }
157
+ for (const hit of searches.values()) hit.search.dispose()
158
+ searches.clear()
159
+ }
160
+
161
+ const searchFor = (groupId: string, root: string): WorkspaceFileSearch => {
162
+ const hit = searches.get(groupId)
163
+ if (hit && hit.root === root) return hit.search
164
+ hit?.search.dispose()
165
+ const search = new WorkspaceFileSearch(root, FILE_SEARCH_CONFIG)
166
+ searches.set(groupId, { root, search })
167
+ return search
168
+ }
169
+
170
+ const fileSearch = async (args: { groupId?: string, query?: string } | undefined): Promise<FileSearchResult> => {
171
+ const groupId = String((args && args.groupId) || '')
172
+ const g = core.groups.get(groupId)
173
+ if (!g) return { ok: false, error: '群组不存在' }
174
+ const raw = String(g.workspaceDir || '').trim()
175
+ if (!raw) return { ok: false, error: '未设置群工作区,无法检索文件' }
176
+ if (raw === '~' || raw === '/') return { ok: false, error: '工作区不能是 ~ 或 /,请设置具体目录' }
177
+ try {
178
+ const res = await resolveMaterialTarget(raw)
179
+ if (res.info === undefined) return { ok: false, error: '工作区目录不存在(尝试过:' + res.tried.join(';') + ')' }
180
+ if (res.info.type !== 'directory') return { ok: false, error: '工作区不是目录:' + res.path }
181
+ const listed = await searchFor(g.id, res.path).list(String((args && args.query) || ''), AbortSignal.timeout(8000))
182
+ return {
183
+ ok: true,
184
+ candidates: listed.map((c) => ({ path: c.path, isDir: c.kind === 'directory' })),
185
+ }
186
+ } catch (e) {
187
+ if (e instanceof Error && e.name === 'TimeoutError') return { ok: false, error: '文件检索超时' }
188
+ return { ok: false, error: String((e && (e as Error).message) || e) }
189
+ }
190
+ }
191
+
192
+ return { loadWorkspaceFiles, materialBlock, browse, fileSearch, disposeFileSearch }
193
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 持久化层统一导出
3
+ * @module dsh-group-chat/host/persistence
4
+ */
5
+
6
+ export { createPersistence } from './persistence.ts'
7
+ export type { Persistence } from './persistence.ts'
@@ -0,0 +1,311 @@
1
+ /**
2
+ * 持久化(PERSISTENCE.md v2.1):事件驱动脏标记合并落盘 + 启动恢复。
3
+ * 锁失败/写失败时降级为内存态运行(console 告警)。
4
+ * @module dsh-group-chat/host/persistence
5
+ */
6
+
7
+ import { existsSync } from 'node:fs'
8
+ import { messageJson, roleJson } from '../../core/json.ts'
9
+ import { asNumber, migrateTier } from '../../core/types.ts'
10
+ import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord } from '../../core/types.ts'
11
+ import { emptyGroup, LedgerDocument, scanGroupIds, scanSessionIds, STORE_DIR, Store } from './store.ts'
12
+ import type { HostState } from '../state.ts'
13
+
14
+ /** 会话文件形态(hydrate 用)。 */
15
+ interface SessionDocument {
16
+ name?: string
17
+ topic?: string
18
+ namePinned?: boolean
19
+ topicPinned?: boolean
20
+ createdAt?: number
21
+ messages?: Partial<MessageRecord>[]
22
+ }
23
+
24
+ /** 群组角色文件形态(hydrate 用)。 */
25
+ interface RolesDocument {
26
+ roles?: Partial<RoleRecord>[]
27
+ }
28
+
29
+ /** 持久化面。 */
30
+ export interface Persistence {
31
+ /** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
32
+ schedulePersist: (targets?: { ledger?: boolean, session?: string | null, roles?: string | null, workspace?: string | null }) => void
33
+ /** 同步 flush:写全部脏文件(dispose 最终落盘用)。 */
34
+ flushNow: () => void
35
+ /** 删除群组/会话后摘除脏标记(对应文件已删/将删,flush 跳过)。 */
36
+ dropDirty: (targets: { session?: string | null, roles?: string | null, workspace?: string | null }) => void
37
+ /** dispose:同步最终 flush → 释放锁 → 摘除句柄。 */
38
+ release: () => void
39
+ }
40
+
41
+ /**
42
+ * 创建持久化面:构造即完成 store 初始化 + hydrate(v1 迁移 → 残留清理 →
43
+ * 清单 → 群组数据 → 会话文件;缺文件空重建;补建默认会话/群组立即落盘)。
44
+ */
45
+ export function createPersistence(core: HostState): Persistence {
46
+ // ---- 持久化:锁失败/写失败时降级为内存态运行(console 告警) ----
47
+ try {
48
+ core.store = new Store(STORE_DIR)
49
+ } catch (e) {
50
+ console.error('[dsh-group-chat] 持久化不可用,本实例以内存态运行(重启后数据不保留):', e)
51
+ core.store = null
52
+ }
53
+ const store = (): Store | null => core.store
54
+
55
+ // 脏标记按文件记;flush 失败者保留脏标记,下次触发自动重试
56
+ const dirtySessions = new Set<string>()
57
+ const dirtyRoles = new Set<string>()
58
+ const dirtyWorkspace = new Set<string>()
59
+ let ledgerDirty = false
60
+ let flushScheduled = false
61
+
62
+ const ledgerDocument = (): LedgerDocument & { savedAt: number } => ({
63
+ schema: 3,
64
+ savedAt: Date.now(),
65
+ groups: [...core.groups.values()].map((g) => ({ id: g.id, name: g.name, permissionTier: g.permissionTier })),
66
+ sessions: [...core.groups.values()].flatMap((g) => g.sessionIds.map((sid) => ({ id: sid, groupId: g.id }))),
67
+ })
68
+ const sessionDocument = (s: SessionRecord) => ({
69
+ schema: 1,
70
+ savedAt: Date.now(),
71
+ id: s.id,
72
+ name: s.name,
73
+ groupId: s.groupId,
74
+ topic: s.topic,
75
+ ...(s.namePinned ? { namePinned: true } : {}),
76
+ ...(s.topicPinned ? { topicPinned: true } : {}),
77
+ createdAt: s.createdAt,
78
+ messages: s.messageIds.map((mid) => core.messages.get(mid)).filter(Boolean).map((m) => messageJson(m)).filter(Boolean),
79
+ })
80
+ const rolesDocument = (g: GroupRecord) => ({ schema: 1, savedAt: Date.now(), roles: g.roleIds.map((rid) => core.roles.get(rid)).filter((r): r is RoleRecord => Boolean(r)).map((r) => roleJson(r)) })
81
+
82
+ /** 同步 flush:写全部脏文件;成功才清脏标记;每个实际发生 rename 的目录一次 fsync。 */
83
+ const flushNow = (): void => {
84
+ const s = store()
85
+ if (s === null) return
86
+ const fsyncDirs = new Set<string>()
87
+ for (const sid of [...dirtySessions]) {
88
+ const sess = core.sessions.get(sid)
89
+ if (!sess) {
90
+ dirtySessions.delete(sid)
91
+ continue
92
+ }
93
+ try {
94
+ s.atomicWrite(s.sessionFile(sess.groupId, sess.id), JSON.stringify(sessionDocument(sess)))
95
+ dirtySessions.delete(sid)
96
+ fsyncDirs.add(s.sessionsDir(sess.groupId))
97
+ } catch (e) {
98
+ console.error(`[dsh-group-chat] 会话 ${sid} 落盘失败(保留脏标记待重试):`, e)
99
+ }
100
+ }
101
+ for (const gid of [...dirtyRoles]) {
102
+ const g = core.groups.get(gid)
103
+ if (!g) {
104
+ dirtyRoles.delete(gid)
105
+ continue
106
+ }
107
+ try {
108
+ s.atomicWrite(s.rolesFile(gid), JSON.stringify(rolesDocument(g)))
109
+ dirtyRoles.delete(gid)
110
+ fsyncDirs.add(s.groupDir(gid))
111
+ } catch (e) {
112
+ console.error(`[dsh-group-chat] 群组 ${gid} roles.json 落盘失败(保留脏标记待重试):`, e)
113
+ }
114
+ }
115
+ for (const gid of [...dirtyWorkspace]) {
116
+ const g = core.groups.get(gid)
117
+ if (!g) {
118
+ dirtyWorkspace.delete(gid)
119
+ continue
120
+ }
121
+ try {
122
+ s.atomicWrite(s.workspaceFile(gid), (g.workspaceDir || '') + '\n')
123
+ dirtyWorkspace.delete(gid)
124
+ fsyncDirs.add(s.groupDir(gid))
125
+ } catch (e) {
126
+ console.error(`[dsh-group-chat] 群组 ${gid} workspaceDir 落盘失败(保留脏标记待重试):`, e)
127
+ }
128
+ }
129
+ if (ledgerDirty) {
130
+ try {
131
+ s.atomicWrite(s.ledgerFile, JSON.stringify(ledgerDocument(), null, 2))
132
+ ledgerDirty = false
133
+ fsyncDirs.add(s.dir)
134
+ } catch (e) {
135
+ console.error('[dsh-group-chat] ledger.json 落盘失败(保留脏标记待重试):', e)
136
+ }
137
+ }
138
+ for (const d of fsyncDirs) s.fsyncDir(d)
139
+ }
140
+
141
+ /** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
142
+ const schedulePersist = ({ ledger = false, session = null, roles: roleGroup = null, workspace = null }: { ledger?: boolean, session?: string | null, roles?: string | null, workspace?: string | null } = {}): void => {
143
+ if (store() === null) return
144
+ if (ledger) ledgerDirty = true
145
+ if (session) dirtySessions.add(session)
146
+ if (roleGroup) dirtyRoles.add(roleGroup)
147
+ if (workspace) dirtyWorkspace.add(workspace)
148
+ if (flushScheduled) return
149
+ flushScheduled = true
150
+ void Promise.resolve().then(() => {
151
+ flushScheduled = false
152
+ flushNow()
153
+ })
154
+ }
155
+
156
+ const dropDirty = ({ session = null, roles: roleGroup = null, workspace = null }: { session?: string | null, roles?: string | null, workspace?: string | null }): void => {
157
+ if (session) dirtySessions.delete(session)
158
+ if (roleGroup) dirtyRoles.delete(roleGroup)
159
+ if (workspace) dirtyWorkspace.delete(workspace)
160
+ }
161
+
162
+ // ---- 启动恢复:v1 迁移 → 残留清理 → 清单 → 群组数据 → 会话文件;缺文件空重建 ----
163
+
164
+ const loadSession = (groupId: string, sid: string, noteMissing: boolean): void => {
165
+ if (core.sessions.has(sid)) return
166
+ const g = core.groups.get(groupId)
167
+ if (!g) return
168
+ const sess: SessionRecord = { id: sid, groupId, name: '会话', topic: '', messageIds: [], createdAt: Date.now() }
169
+ let doc: SessionDocument | null = null
170
+ const s = store()
171
+ if (s) {
172
+ const file = s.sessionFile(groupId, sid)
173
+ if (noteMissing && !existsSync(file)) console.error(`[dsh-group-chat] 会话 ${sid} 文件缺失,按空消息重建`)
174
+ doc = s.loadJson(file) as SessionDocument | null
175
+ }
176
+ if (doc) {
177
+ if (typeof doc.name === 'string' && doc.name) sess.name = doc.name
178
+ if (typeof doc.topic === 'string') sess.topic = doc.topic
179
+ if (doc.namePinned === true) sess.namePinned = true
180
+ if (doc.topicPinned === true) sess.topicPinned = true
181
+ if (typeof doc.createdAt === 'number') sess.createdAt = doc.createdAt
182
+ let fallbackSeq = 0
183
+ for (const m of (Array.isArray(doc.messages) ? doc.messages : [])) {
184
+ if (!m || typeof m.id !== 'string' || core.messages.has(m.id)) continue
185
+ fallbackSeq++
186
+ core.messages.set(m.id, {
187
+ id: m.id,
188
+ sessionId: sid,
189
+ seq: typeof m.seq === 'number' ? m.seq : fallbackSeq,
190
+ speaker: m.speaker ?? '',
191
+ text: String(m.text || ''),
192
+ reasoning: m.reasoning !== undefined ? String(m.reasoning) : undefined,
193
+ reasoningFull: m.reasoningFull !== undefined ? String(m.reasoningFull) : undefined,
194
+ thinkingSummary: m.thinkingSummary !== undefined ? String(m.thinkingSummary) : undefined,
195
+ model: m.model,
196
+ error: m.error,
197
+ toolCalls: Array.isArray(m.toolCalls) ? m.toolCalls : undefined,
198
+ ts: typeof m.ts === 'number' ? m.ts : Date.now(),
199
+ })
200
+ sess.messageIds.push(m.id)
201
+ }
202
+ }
203
+ core.sessions.set(sid, sess)
204
+ g.sessionIds.push(sid)
205
+ }
206
+
207
+ const loadGroupData = (g: GroupRecord): void => {
208
+ const s = store()
209
+ if (!s) return
210
+ g.workspaceDir = s.readWorkspace(g.id)
211
+ const doc = s.loadJson(s.rolesFile(g.id)) as RolesDocument | null
212
+ if (doc && Array.isArray(doc.roles)) {
213
+ for (const r of doc.roles) {
214
+ if (r && typeof r.id === 'string' && !core.roles.has(r.id)) {
215
+ core.roles.set(r.id, {
216
+ id: r.id,
217
+ groupId: g.id,
218
+ name: String(r.name || '成员'),
219
+ color: r.color || undefined,
220
+ persona: String(r.persona || ''),
221
+ provider: String(r.provider || ''),
222
+ model: String(r.model || ''),
223
+ temperature: asNumber(r.temperature),
224
+ reasoningEffort: typeof r.reasoningEffort === 'string' && r.reasoningEffort ? r.reasoningEffort : undefined,
225
+ enabled: r.enabled !== false,
226
+ thinking: r.thinking === true,
227
+ })
228
+ g.roleIds.push(r.id)
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ const hydrate = (): void => {
235
+ const s = store()
236
+ if (s) {
237
+ try {
238
+ s.migrateV1()
239
+ } catch (e) {
240
+ console.error('[dsh-group-chat] v1 数据迁移失败,下次启动重试:', e)
241
+ }
242
+ try {
243
+ s.cleanup()
244
+ } catch {}
245
+ }
246
+ const ledger = s ? (s.loadJson(s.ledgerFile) as LedgerDocument | null) : null
247
+ if (ledger && Array.isArray(ledger.groups)) {
248
+ for (const g of ledger.groups) {
249
+ if (g && typeof g.id === 'string' && !core.groups.has(g.id)) {
250
+ core.groups.set(g.id, { id: g.id, name: String(g.name || '群组'), workspaceDir: '', permissionTier: migrateTier(g.permissionTier, g.allowCommands), roleIds: [], sessionIds: [] })
251
+ }
252
+ }
253
+ for (const g of core.groups.values()) loadGroupData(g)
254
+ for (const item of (Array.isArray(ledger.sessions) ? ledger.sessions : [])) {
255
+ if (item && typeof item.id === 'string' && core.groups.has(item.groupId)) loadSession(item.groupId, item.id, true)
256
+ }
257
+ } else if (s) {
258
+ // ledger 缺失/损坏:扫描群组目录回收(群组名丢失,角色/工作区/会话从文件恢复)
259
+ for (const gid of scanGroupIds(STORE_DIR)) {
260
+ const g = emptyGroup(gid, '群组')
261
+ core.groups.set(g.id, g)
262
+ loadGroupData(g)
263
+ for (const sid of scanSessionIds(s, gid)) loadSession(gid, sid, false)
264
+ }
265
+ }
266
+ const autoSessions: string[] = []
267
+ for (const g of core.groups.values()) {
268
+ if (g.sessionIds.length === 0) {
269
+ const sess = core.newSession(g.id)
270
+ g.sessionIds.push(sess.id)
271
+ autoSessions.push(sess.id)
272
+ }
273
+ }
274
+ if (core.groups.size === 0) {
275
+ const g: GroupRecord = { id: core.nid('grp'), name: '默认群组', workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
276
+ core.groups.set(g.id, g)
277
+ const sess = core.newSession(g.id, '会话 1')
278
+ g.sessionIds.push(sess.id)
279
+ autoSessions.push(sess.id)
280
+ }
281
+ let maxSeq = 0
282
+ for (const id of [...core.groups.keys(), ...core.roles.keys(), ...core.messages.keys()]) {
283
+ const m = /-(\d+)$/.exec(id)
284
+ if (m) maxSeq = Math.max(maxSeq, Number(m[1]))
285
+ }
286
+ core.idSeq = maxSeq + 1
287
+ // 补建的默认会话/群组立即落盘,保证 ledger 与会话文件引用一致
288
+ if (autoSessions.length > 0) {
289
+ schedulePersist({ ledger: true })
290
+ for (const sid of autoSessions) schedulePersist({ session: sid })
291
+ }
292
+ }
293
+ hydrate()
294
+
295
+ return {
296
+ schedulePersist,
297
+ flushNow,
298
+ dropDirty,
299
+ release(): void {
300
+ const s = store()
301
+ if (s === null) return
302
+ try {
303
+ flushNow()
304
+ } catch {}
305
+ try {
306
+ s.release()
307
+ } catch {}
308
+ core.store = null
309
+ },
310
+ }
311
+ }