@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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +3308 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +2583 -0
- package/lib/types/client/Bubble.d.ts +10 -0
- package/lib/types/client/Glyph.d.ts +12 -0
- package/lib/types/client/GroupChatPanel.d.ts +10 -0
- package/lib/types/client/GroupChatPanel.old.d.ts +10 -0
- package/lib/types/client/GroupChatSettingsSection.d.ts +15 -0
- package/lib/types/client/PermissionSelect.d.ts +13 -0
- package/lib/types/client/RoleDrawer.d.ts +17 -0
- package/lib/types/client/ThinkRow.d.ts +9 -0
- package/lib/types/client/ToolRow.d.ts +9 -0
- package/lib/types/client/api.d.ts +8 -0
- package/lib/types/client/components/AsidePanel.d.ts +31 -0
- package/lib/types/client/components/Bubble.d.ts +19 -0
- package/lib/types/client/components/ChatPanel.d.ts +57 -0
- package/lib/types/client/components/Composer.d.ts +48 -0
- package/lib/types/client/components/Glyph.d.ts +12 -0
- package/lib/types/client/components/GroupChatSettingsSection.d.ts +15 -0
- package/lib/types/client/components/MessageFlow.d.ts +15 -0
- package/lib/types/client/components/NavPanel.d.ts +40 -0
- package/lib/types/client/components/PermissionSelect.d.ts +13 -0
- package/lib/types/client/components/RoleDrawer.d.ts +17 -0
- package/lib/types/client/components/ThinkRow.d.ts +9 -0
- package/lib/types/client/components/ToolRow.d.ts +9 -0
- package/lib/types/client/components/index.d.ts +9 -0
- package/lib/types/client/components.d.ts +22 -0
- package/lib/types/client/drawer.d.ts +17 -0
- package/lib/types/client/glyph.d.ts +12 -0
- package/lib/types/client/hooks/index.d.ts +6 -0
- package/lib/types/client/hooks/useComposer.d.ts +27 -0
- package/lib/types/client/hooks/useFileSearch.d.ts +19 -0
- package/lib/types/client/hooks/useGroupChatState.d.ts +95 -0
- package/lib/types/client/hooks/useMentionState.d.ts +27 -0
- package/lib/types/client/index.d.ts +52 -0
- package/lib/types/client/lib/api.d.ts +8 -0
- package/lib/types/client/lib/model.d.ts +80 -0
- package/lib/types/client/lib/styles.d.ts +10 -0
- package/lib/types/client/lib/ui.d.ts +17 -0
- package/lib/types/client/model.d.ts +78 -0
- package/lib/types/client/panel.d.ts +10 -0
- package/lib/types/client/settings.d.ts +15 -0
- package/lib/types/client/styles.d.ts +10 -0
- package/lib/types/client/ui.d.ts +17 -0
- package/lib/types/client/utils/utils.d.ts +29 -0
- package/lib/types/client/utils.d.ts +22 -0
- package/lib/types/core/json.d.ts +9 -0
- package/lib/types/core/status.d.ts +18 -0
- package/lib/types/core/tools.d.ts +59 -0
- package/lib/types/core/types.d.ts +256 -0
- package/lib/types/host/actions.d.ts +34 -0
- package/lib/types/host/api/actions.d.ts +36 -0
- package/lib/types/host/api/http.d.ts +16 -0
- package/lib/types/host/api/index.d.ts +7 -0
- package/lib/types/host/api/routes.d.ts +11 -0
- package/lib/types/host/broadcast.d.ts +20 -0
- package/lib/types/host/conversation.d.ts +25 -0
- package/lib/types/host/engine/conversation.d.ts +25 -0
- package/lib/types/host/engine/index.d.ts +7 -0
- package/lib/types/host/engine/retitle.d.ts +20 -0
- package/lib/types/host/http.d.ts +16 -0
- package/lib/types/host/index.d.ts +8 -0
- package/lib/types/host/materials/index.d.ts +6 -0
- package/lib/types/host/materials/materials.d.ts +40 -0
- package/lib/types/host/materials.d.ts +32 -0
- package/lib/types/host/persistence/index.d.ts +6 -0
- package/lib/types/host/persistence/persistence.d.ts +31 -0
- package/lib/types/host/persistence/store.d.ts +65 -0
- package/lib/types/host/persistence.d.ts +31 -0
- package/lib/types/host/routes.d.ts +11 -0
- package/lib/types/host/service.d.ts +28 -0
- package/lib/types/host/state.d.ts +45 -0
- package/lib/types/host/store.d.ts +65 -0
- package/lib/types/host/tools/index.d.ts +6 -0
- package/lib/types/host/tools/tools.d.ts +28 -0
- package/lib/types/host/tools.d.ts +28 -0
- package/lib/types/index.d.ts +41 -0
- package/lib/types/mount-once.d.ts +9 -0
- package/lib/types/shared/file-mention-grammar.d.ts +37 -0
- package/lib/types/shared/file-mention-grammar.test.d.ts +5 -0
- package/package.json +98 -0
- package/src/client/GroupChatPanel.tsx +385 -0
- package/src/client/components/AsidePanel.tsx +188 -0
- package/src/client/components/Bubble.tsx +76 -0
- package/src/client/components/ChatPanel.tsx +214 -0
- package/src/client/components/Composer.tsx +238 -0
- package/src/client/components/Glyph.tsx +27 -0
- package/src/client/components/GroupChatSettingsSection.tsx +74 -0
- package/src/client/components/MessageFlow.tsx +97 -0
- package/src/client/components/NavPanel.tsx +249 -0
- package/src/client/components/PermissionSelect.tsx +159 -0
- package/src/client/components/RoleDrawer.tsx +186 -0
- package/src/client/components/ThinkRow.tsx +32 -0
- package/src/client/components/ToolRow.tsx +51 -0
- package/src/client/components/index.ts +10 -0
- package/src/client/hooks/index.ts +7 -0
- package/src/client/hooks/useComposer.ts +272 -0
- package/src/client/hooks/useFileSearch.ts +115 -0
- package/src/client/hooks/useGroupChatState.ts +214 -0
- package/src/client/hooks/useMentionState.ts +56 -0
- package/src/client/index.ts +110 -0
- package/src/client/lib/api.ts +23 -0
- package/src/client/lib/model.ts +119 -0
- package/src/client/lib/styles.ts +317 -0
- package/src/client/lib/ui.ts +27 -0
- package/src/client/react-dom-shim.d.ts +13 -0
- package/src/client/utils/utils.ts +104 -0
- package/src/core/json.ts +28 -0
- package/src/core/status.ts +33 -0
- package/src/core/tools.ts +41 -0
- package/src/core/types.ts +222 -0
- package/src/host/api/actions.ts +335 -0
- package/src/host/api/http.ts +80 -0
- package/src/host/api/index.ts +8 -0
- package/src/host/api/routes.ts +95 -0
- package/src/host/broadcast.ts +65 -0
- package/src/host/engine/conversation.ts +331 -0
- package/src/host/engine/index.ts +8 -0
- package/src/host/engine/retitle.ts +135 -0
- package/src/host/index.ts +9 -0
- package/src/host/materials/index.ts +7 -0
- package/src/host/materials/materials.ts +193 -0
- package/src/host/persistence/index.ts +7 -0
- package/src/host/persistence/persistence.ts +311 -0
- package/src/host/persistence/store.ts +375 -0
- package/src/host/service.ts +78 -0
- package/src/host/state.ts +85 -0
- package/src/host/tools/index.ts +7 -0
- package/src/host/tools/tools.ts +194 -0
- package/src/index.ts +99 -0
- package/src/mount-once.ts +49 -0
- package/src/shared/file-mention-grammar.test.ts +77 -0
- package/src/shared/file-mention-grammar.ts +82 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 持久化(PERSISTENCE.md v2.1:会话级文件隔离)。
|
|
3
|
+
*
|
|
4
|
+
* 目录布局(每群组一目录,一会话一文件):
|
|
5
|
+
* <dir>/ledger.json 群组/会话清单(schema 3,纯清单,pretty;群组含 permissionTier)
|
|
6
|
+
* <dir>/<group-id>/workspaceDir 工作区目录设置(纯文本一行)
|
|
7
|
+
* <dir>/<group-id>/roles.json 群组角色(schema 1,紧凑)
|
|
8
|
+
* <dir>/<group-id>/sessions/session-<uuid>.json 会话(schema 1,自包含,紧凑)
|
|
9
|
+
*
|
|
10
|
+
* 原子写 tmp+fsync+rename(每目录一次 fsync);单实例 .lock;损坏隔离重建;
|
|
11
|
+
* 写失败保留脏标记;v1 自动迁移。
|
|
12
|
+
* @module dsh-group-chat/host/persistence/store
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
17
|
+
import { randomUUID } from 'node:crypto'
|
|
18
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { messageJson, roleJson } from '../../core/json.ts'
|
|
20
|
+
import type { GroupRecord } from '../../core/types.ts'
|
|
21
|
+
|
|
22
|
+
/** DSH 主目录;DSH_GROUP_CHAT_STORE 供测试覆盖存储位置。 */
|
|
23
|
+
export const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
24
|
+
|
|
25
|
+
/** 存储根目录。 */
|
|
26
|
+
export const STORE_DIR = process.env.DSH_GROUP_CHAT_STORE || join(DSH_HOME, 'storages', 'group-chat')
|
|
27
|
+
|
|
28
|
+
const processIsAlive = (pid: number): boolean => {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0)
|
|
31
|
+
return true
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return (e as NodeJS.ErrnoException).code === 'EPERM'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SESSION_FILE_RE = /^session-(.+)\.json$/
|
|
38
|
+
|
|
39
|
+
/** 会话文件名 → 会话 id。 */
|
|
40
|
+
export function sessionFileMatch(name: string): string | null {
|
|
41
|
+
const m = SESSION_FILE_RE.exec(name)
|
|
42
|
+
return m ? m[1] : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 群组目录下的 v1 遗留会话清单形态(迁移用)。 */
|
|
46
|
+
interface V1Ledger {
|
|
47
|
+
schema?: number
|
|
48
|
+
groups?: { id: string, name?: string, workspaceDir?: string }[]
|
|
49
|
+
roles?: { id: string, groupId: string }[]
|
|
50
|
+
sessions?: { id: string, groupId: string, name?: string, topic?: string, createdAt?: number }[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class Store {
|
|
54
|
+
readonly dir: string
|
|
55
|
+
readonly ledgerFile: string
|
|
56
|
+
private readonly lockFile: string
|
|
57
|
+
private lockFd: number | undefined
|
|
58
|
+
|
|
59
|
+
constructor(dir: string) {
|
|
60
|
+
this.dir = dir
|
|
61
|
+
this.ledgerFile = join(dir, 'ledger.json')
|
|
62
|
+
this.lockFile = join(dir, '.lock')
|
|
63
|
+
mkdirSync(dir, { recursive: true })
|
|
64
|
+
this.lockFd = this.acquireLock()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
groupDir(groupId: string): string { return join(this.dir, groupId) }
|
|
68
|
+
sessionsDir(groupId: string): string { return join(this.groupDir(groupId), 'sessions') }
|
|
69
|
+
sessionFile(groupId: string, sessionId: string): string { return join(this.sessionsDir(groupId), 'session-' + sessionId + '.json') }
|
|
70
|
+
rolesFile(groupId: string): string { return join(this.groupDir(groupId), 'roles.json') }
|
|
71
|
+
workspaceFile(groupId: string): string { return join(this.groupDir(groupId), 'workspaceDir') }
|
|
72
|
+
|
|
73
|
+
private acquireLock(): number {
|
|
74
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
75
|
+
try {
|
|
76
|
+
const fd = openSync(this.lockFile, 'wx', 0o600)
|
|
77
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, token: randomUUID(), at: Date.now() }), 'utf8')
|
|
78
|
+
try {
|
|
79
|
+
fsyncSync(fd)
|
|
80
|
+
} catch {}
|
|
81
|
+
try {
|
|
82
|
+
chmodSync(this.lockFile, 0o600)
|
|
83
|
+
} catch {}
|
|
84
|
+
return fd
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
87
|
+
let pid: number | undefined
|
|
88
|
+
try {
|
|
89
|
+
pid = JSON.parse(readFileSync(this.lockFile, 'utf8')).pid
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error(`持久化锁不可读,如确认无其他 dsh web 在运行可手动删除 ${this.lockFile}`)
|
|
92
|
+
}
|
|
93
|
+
if (typeof pid === 'number' && processIsAlive(pid)) {
|
|
94
|
+
throw new Error(`持久化锁被进程 ${pid} 持有`)
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
unlinkSync(this.lockFile)
|
|
98
|
+
} catch (ue) {
|
|
99
|
+
if ((ue as NodeJS.ErrnoException).code !== 'ENOENT') throw ue
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw new Error('持久化锁获取失败')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
release(): void {
|
|
107
|
+
if (this.lockFd !== undefined) {
|
|
108
|
+
try {
|
|
109
|
+
closeSync(this.lockFd)
|
|
110
|
+
} catch {}
|
|
111
|
+
try {
|
|
112
|
+
unlinkSync(this.lockFile)
|
|
113
|
+
} catch {}
|
|
114
|
+
this.lockFd = undefined
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fsyncDir(dir: string): void {
|
|
119
|
+
try {
|
|
120
|
+
const fd = openSync(dir, 'r')
|
|
121
|
+
try {
|
|
122
|
+
fsyncSync(fd)
|
|
123
|
+
} finally {
|
|
124
|
+
closeSync(fd)
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 原子写:tmp+fsync+rename;目录 fsync 由调用方按 flush 批量执行(每目录一次)。 */
|
|
130
|
+
atomicWrite(file: string, text: string): void {
|
|
131
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
132
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
133
|
+
let fd: number | undefined
|
|
134
|
+
try {
|
|
135
|
+
fd = openSync(tmp, 'w', 0o600)
|
|
136
|
+
writeFileSync(fd, text, 'utf8')
|
|
137
|
+
fsyncSync(fd)
|
|
138
|
+
closeSync(fd)
|
|
139
|
+
fd = undefined
|
|
140
|
+
try {
|
|
141
|
+
chmodSync(tmp, 0o600)
|
|
142
|
+
} catch {}
|
|
143
|
+
renameSync(tmp, file)
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (fd !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
closeSync(fd)
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
unlinkSync(tmp)
|
|
152
|
+
} catch {}
|
|
153
|
+
throw e
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
quarantine(file: string): void {
|
|
158
|
+
try {
|
|
159
|
+
renameSync(file, `${file}.corrupt-${Date.now()}-${process.pid}`)
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 读 JSON;缺失返回 null;损坏则隔离后返回 null。 */
|
|
164
|
+
loadJson(file: string): unknown {
|
|
165
|
+
let raw: string
|
|
166
|
+
try {
|
|
167
|
+
raw = readFileSync(file, 'utf8')
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
170
|
+
throw e
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(raw)
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error(`[dsh-group-chat] ${basename(file)} 解析失败,已隔离并以空数据启动:`, e)
|
|
176
|
+
this.quarantine(file)
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
readWorkspace(groupId: string): string {
|
|
182
|
+
try {
|
|
183
|
+
return readFileSync(this.workspaceFile(groupId), 'utf8').trim()
|
|
184
|
+
} catch {
|
|
185
|
+
return ''
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** hydrate 残留清理:.tmp-* 删除;同前缀 .corrupt-* 只保留最近 1 份。 */
|
|
190
|
+
cleanup(): void {
|
|
191
|
+
const sweep = (dir: string) => {
|
|
192
|
+
let entries: import('node:fs').Dirent[]
|
|
193
|
+
try {
|
|
194
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
195
|
+
} catch {
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
const corrupt = new Map<string, { name: string, mtime: number }[]>()
|
|
199
|
+
for (const e of entries) {
|
|
200
|
+
if (!e.isFile()) continue
|
|
201
|
+
if (/\.tmp-\d+$/.test(e.name)) {
|
|
202
|
+
try {
|
|
203
|
+
unlinkSync(join(dir, e.name))
|
|
204
|
+
} catch {}
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
const m = /^(.+)\.corrupt-\d+-\d+$/.exec(e.name)
|
|
208
|
+
if (m) {
|
|
209
|
+
let mtime = 0
|
|
210
|
+
try {
|
|
211
|
+
mtime = statSync(join(dir, e.name)).mtimeMs
|
|
212
|
+
} catch {}
|
|
213
|
+
if (!corrupt.has(m[1])) corrupt.set(m[1], [])
|
|
214
|
+
corrupt.get(m[1])!.push({ name: e.name, mtime })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const list of corrupt.values()) {
|
|
218
|
+
list.sort((a, b) => b.mtime - a.mtime)
|
|
219
|
+
for (const item of list.slice(1)) {
|
|
220
|
+
try {
|
|
221
|
+
unlinkSync(join(dir, item.name))
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
sweep(this.dir)
|
|
227
|
+
let entries: import('node:fs').Dirent[]
|
|
228
|
+
try {
|
|
229
|
+
entries = readdirSync(this.dir, { withFileTypes: true })
|
|
230
|
+
} catch {
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
for (const e of entries) {
|
|
234
|
+
if (!e.isDirectory()) continue
|
|
235
|
+
const gdir = join(this.dir, e.name)
|
|
236
|
+
sweep(gdir)
|
|
237
|
+
sweep(join(gdir, 'sessions'))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** v1(ledger+messages 双文件)→ v2 一次性迁移;幂等,messages.json 只归档从不删除。 */
|
|
242
|
+
migrateV1(): void {
|
|
243
|
+
const v1MessagesFile = join(this.dir, 'messages.json')
|
|
244
|
+
if (!existsSync(v1MessagesFile)) return
|
|
245
|
+
const v1Ledger = this.loadJson(this.ledgerFile) as V1Ledger | null
|
|
246
|
+
const archive = () => {
|
|
247
|
+
try {
|
|
248
|
+
renameSync(v1MessagesFile, `${v1MessagesFile}.migrated-${Date.now()}`)
|
|
249
|
+
} catch {}
|
|
250
|
+
this.fsyncDir(this.dir)
|
|
251
|
+
}
|
|
252
|
+
// schema 2 = 上次迁移已完成、仅剩归档 rename 未做;无有效 v1 清单 = 消息不可归属:均只归档兜底
|
|
253
|
+
if (!v1Ledger || v1Ledger.schema === 2 || !Array.isArray(v1Ledger.groups)) {
|
|
254
|
+
archive()
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
// 1. 清上次迁移半成品(v1 清单仍是事实源,安全幂等)
|
|
258
|
+
let dirs: import('node:fs').Dirent[]
|
|
259
|
+
try {
|
|
260
|
+
dirs = readdirSync(this.dir, { withFileTypes: true })
|
|
261
|
+
} catch {
|
|
262
|
+
dirs = []
|
|
263
|
+
}
|
|
264
|
+
for (const e of dirs) {
|
|
265
|
+
if (!e.isDirectory()) continue
|
|
266
|
+
const gdir = join(this.dir, e.name)
|
|
267
|
+
try {
|
|
268
|
+
rmSync(join(gdir, 'sessions'), { recursive: true, force: true })
|
|
269
|
+
} catch {}
|
|
270
|
+
for (const name of ['roles.json', 'workspaceDir']) {
|
|
271
|
+
try {
|
|
272
|
+
unlinkSync(join(gdir, name))
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// 2. v1 数据整备
|
|
277
|
+
const groupsById = new Map<string, NonNullable<V1Ledger['groups']>[number]>()
|
|
278
|
+
for (const g of v1Ledger.groups) {
|
|
279
|
+
if (g && typeof g.id === 'string') groupsById.set(g.id, g)
|
|
280
|
+
}
|
|
281
|
+
const rolesByGroup = new Map<string, { id: string, groupId: string }[]>()
|
|
282
|
+
for (const r of (Array.isArray(v1Ledger.roles) ? v1Ledger.roles : [])) {
|
|
283
|
+
if (r && typeof r.id === 'string' && groupsById.has(r.groupId)) {
|
|
284
|
+
if (!rolesByGroup.has(r.groupId)) rolesByGroup.set(r.groupId, [])
|
|
285
|
+
rolesByGroup.get(r.groupId)!.push(r)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const v1Sessions = (Array.isArray(v1Ledger.sessions) ? v1Ledger.sessions : []).filter((s) => s && typeof s.id === 'string' && groupsById.has(s.groupId))
|
|
289
|
+
const sessionIds = new Set(v1Sessions.map((s) => s.id))
|
|
290
|
+
const messagesDoc = this.loadJson(v1MessagesFile) as { messages?: { id: string, sessionId: string }[] } | null
|
|
291
|
+
const messagesBySession = new Map<string, { id: string, sessionId: string }[]>()
|
|
292
|
+
for (const m of (messagesDoc && Array.isArray(messagesDoc.messages) ? messagesDoc.messages : [])) {
|
|
293
|
+
if (m && typeof m.id === 'string' && sessionIds.has(m.sessionId)) {
|
|
294
|
+
if (!messagesBySession.has(m.sessionId)) messagesBySession.set(m.sessionId, [])
|
|
295
|
+
messagesBySession.get(m.sessionId)!.push(m)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// 3. 每会话新 uuid,写会话文件(紧凑)
|
|
299
|
+
const fsyncDirs = new Set<string>()
|
|
300
|
+
const uuidById = new Map<string, string>()
|
|
301
|
+
for (const s of v1Sessions) {
|
|
302
|
+
const uuid = randomUUID()
|
|
303
|
+
uuidById.set(s.id, uuid)
|
|
304
|
+
const doc = {
|
|
305
|
+
schema: 1,
|
|
306
|
+
savedAt: Date.now(),
|
|
307
|
+
id: uuid,
|
|
308
|
+
groupId: s.groupId,
|
|
309
|
+
name: String(s.name || '会话'),
|
|
310
|
+
topic: String(s.topic || ''),
|
|
311
|
+
createdAt: typeof s.createdAt === 'number' ? s.createdAt : Date.now(),
|
|
312
|
+
messages: (messagesBySession.get(s.id) || []).map((m) => messageJson(m)).filter(Boolean),
|
|
313
|
+
}
|
|
314
|
+
this.atomicWrite(this.sessionFile(s.groupId, uuid), JSON.stringify(doc))
|
|
315
|
+
fsyncDirs.add(this.sessionsDir(s.groupId))
|
|
316
|
+
}
|
|
317
|
+
// 4/5. 每群 roles.json + workspaceDir
|
|
318
|
+
for (const g of groupsById.values()) {
|
|
319
|
+
this.atomicWrite(this.rolesFile(g.id), JSON.stringify({ schema: 1, savedAt: Date.now(), roles: (rolesByGroup.get(g.id) || []).map((r) => roleJson(r)) }))
|
|
320
|
+
this.atomicWrite(this.workspaceFile(g.id), String(g.workspaceDir || '').trim() + '\n')
|
|
321
|
+
fsyncDirs.add(this.groupDir(g.id))
|
|
322
|
+
}
|
|
323
|
+
// 6. 新 ledger(纯清单,pretty)
|
|
324
|
+
this.atomicWrite(this.ledgerFile, JSON.stringify({
|
|
325
|
+
schema: 2,
|
|
326
|
+
savedAt: Date.now(),
|
|
327
|
+
groups: [...groupsById.values()].map((g) => ({ id: g.id, name: String(g.name || '群组') })),
|
|
328
|
+
sessions: v1Sessions.map((s) => ({ id: uuidById.get(s.id), groupId: s.groupId })),
|
|
329
|
+
}, null, 2))
|
|
330
|
+
fsyncDirs.add(this.dir)
|
|
331
|
+
for (const d of fsyncDirs) this.fsyncDir(d)
|
|
332
|
+
// 7. 归档旧 messages.json(最后一步)
|
|
333
|
+
archive()
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** ledger 清单的读取形态(hydrate 用;allowCommands 为 v2 遗留字段,读取时经 migrateTier 迁移)。 */
|
|
338
|
+
export interface LedgerDocument {
|
|
339
|
+
schema?: number
|
|
340
|
+
groups?: { id: string, name?: string, permissionTier?: string, allowCommands?: boolean }[]
|
|
341
|
+
sessions?: { id: string, groupId: string }[]
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 从磁盘目录回收群组 id 列表(ledger 缺失/损坏时的兜底路径)。 */
|
|
345
|
+
export function scanGroupIds(dir: string): string[] {
|
|
346
|
+
let entries: import('node:fs').Dirent[]
|
|
347
|
+
try {
|
|
348
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
349
|
+
} catch {
|
|
350
|
+
return []
|
|
351
|
+
}
|
|
352
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 扫描群组目录下的会话文件(ledger 缺失/损坏时的兜底路径)。 */
|
|
356
|
+
export function scanSessionIds(store: Store, groupId: string): string[] {
|
|
357
|
+
let entries: import('node:fs').Dirent[]
|
|
358
|
+
try {
|
|
359
|
+
entries = readdirSync(store.sessionsDir(groupId), { withFileTypes: true })
|
|
360
|
+
} catch {
|
|
361
|
+
return []
|
|
362
|
+
}
|
|
363
|
+
const ids: string[] = []
|
|
364
|
+
for (const se of entries) {
|
|
365
|
+
if (!se.isFile()) continue
|
|
366
|
+
const m = sessionFileMatch(se.name)
|
|
367
|
+
if (m) ids.push(m)
|
|
368
|
+
}
|
|
369
|
+
return ids
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 空群组记录构造(hydrate 兜底路径用)。 */
|
|
373
|
+
export function emptyGroup(id: string, name: string): GroupRecord {
|
|
374
|
+
return { id, name, workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
|
|
375
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群聊宿主服务(组合根):装配各层模块并暴露对路由/入口的面。
|
|
3
|
+
* 目录分层(对齐 src/client 的分层模式:每层一目录 + index.ts barrel):
|
|
4
|
+
* state.ts 共享状态容器(四张表 + run + 可变槽位;kernel,根级)
|
|
5
|
+
* broadcast.ts SSE 广播(节流)+ touch + 全量快照(kernel 读模型,根级)
|
|
6
|
+
* persistence/ 持久化(store 文件原语 + 脏标记合并落盘 + 启动恢复)
|
|
7
|
+
* materials/ 资料读取与路径解析 + 目录浏览器
|
|
8
|
+
* tools/ 工具执行(沙箱 + 确认闸门)
|
|
9
|
+
* engine/ 对话引擎(conversation:speak/runLoop;retitle:标题整理)
|
|
10
|
+
* api/ HTTP 传输(http 护栏 + routes 路由)与动作分发(actions)
|
|
11
|
+
* @module dsh-group-chat/host/service
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import type { Snapshot } from '../core/types.ts'
|
|
16
|
+
import { createActions } from './api/index.ts'
|
|
17
|
+
import { createBroadcast } from './broadcast.ts'
|
|
18
|
+
import { createConversation } from './engine/index.ts'
|
|
19
|
+
import { createMaterials } from './materials/index.ts'
|
|
20
|
+
import { createPersistence } from './persistence/index.ts'
|
|
21
|
+
import { createHostState } from './state.ts'
|
|
22
|
+
import { createTools } from './tools/index.ts'
|
|
23
|
+
|
|
24
|
+
/** 服务对路由/入口暴露的面。 */
|
|
25
|
+
export interface GroupChatService {
|
|
26
|
+
snapshot(): Snapshot
|
|
27
|
+
handleAction(body: unknown): Promise<unknown>
|
|
28
|
+
subscribePush(push: () => void): () => void
|
|
29
|
+
/** 设置停用时中止正在进行的群聊。 */
|
|
30
|
+
stopAll(): void
|
|
31
|
+
/** 卸载/热重载:唤醒确认等待 + kill 子进程 → 同步最终 flush → 释放锁。 */
|
|
32
|
+
dispose(): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 创建群聊宿主服务(装配各模块;持久化锁失败时降级内存态运行)。
|
|
37
|
+
*/
|
|
38
|
+
export function createGroupChatService(ctx: Context): GroupChatService {
|
|
39
|
+
const core = createHostState(ctx)
|
|
40
|
+
const persist = createPersistence(core) // 构造即 store 初始化 + hydrate
|
|
41
|
+
const bus = createBroadcast(core)
|
|
42
|
+
const materials = createMaterials(core)
|
|
43
|
+
const tools = createTools(core, bus.touch)
|
|
44
|
+
const conversation = createConversation(core, { touch: bus.touch, schedulePersist: persist.schedulePersist, materials, tools })
|
|
45
|
+
const actions = createActions(core, {
|
|
46
|
+
touch: bus.touch,
|
|
47
|
+
snapshot: bus.snapshot,
|
|
48
|
+
schedulePersist: persist.schedulePersist,
|
|
49
|
+
dropDirty: persist.dropDirty,
|
|
50
|
+
appendMessage: conversation.appendMessage,
|
|
51
|
+
runLoop: conversation.runLoop,
|
|
52
|
+
wakeConfirm: tools.wakeConfirm,
|
|
53
|
+
killChild: tools.killChild,
|
|
54
|
+
browse: materials.browse,
|
|
55
|
+
fileSearch: materials.fileSearch,
|
|
56
|
+
disposeFileSearch: materials.disposeFileSearch,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
snapshot: bus.snapshot,
|
|
61
|
+
handleAction: actions.handleAction,
|
|
62
|
+
subscribePush: bus.subscribePush,
|
|
63
|
+
stopAll(): void {
|
|
64
|
+
if (core.run.running) {
|
|
65
|
+
core.run.stopping = true
|
|
66
|
+
bus.touch()
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
dispose(): void {
|
|
70
|
+
core.run.stopping = true
|
|
71
|
+
if (core.run.pendingConfirm) tools.wakeConfirm()
|
|
72
|
+
tools.killChild()
|
|
73
|
+
materials.disposeFileSearch()
|
|
74
|
+
bus.dispose()
|
|
75
|
+
persist.release()
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群聊宿主服务共享状态容器:四张表 + run(对话进行时状态)+ 可变槽位
|
|
3
|
+
* (store / revision / idSeq / lastCreated)。各功能模块(persistence /
|
|
4
|
+
* broadcast / materials / tools / conversation / actions)以工厂装配到同一
|
|
5
|
+
* 容器上,模块间只经显式依赖传递;容器本身只含数据与稳定引用,不含行为。
|
|
6
|
+
* @module dsh-group-chat/host/state
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
// 类型合并:ctx.llm / ctx.fs / ctx.workspaceRegistry(宿主面)
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-llm'
|
|
13
|
+
import type {} from '@deepseek-ai/dsh-fs'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-workspace'
|
|
15
|
+
import type { Store } from './persistence/store.ts'
|
|
16
|
+
import type { GroupRecord, LastCreated, MessageRecord, RoleRecord, RunState, SessionRecord } from '../core/types.ts'
|
|
17
|
+
|
|
18
|
+
// 可选类型镜像:DSH 默认模型选择服务(宿主 agent 栈提供)。不硬依赖该包、
|
|
19
|
+
// 不加入 inject(服务缺位时插件照常加载),运行时缺位则自动命名静默跳过。
|
|
20
|
+
// 消费必须经 ctx.reflect.get(见 engine/retitle.ts):cordis 对未 inject 的
|
|
21
|
+
// 属性访问直接抛错("cannot get property without inject"),`?.` 接不住。
|
|
22
|
+
declare module '@deepseek-ai/cordis' {
|
|
23
|
+
interface Context {
|
|
24
|
+
agentDefaultModel?: { currentSelection(): { provider: string, model: string } }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 角色标识色板(新增角色依序取色)。 */
|
|
29
|
+
export const PALETTE = ['#5b8def', '#22a06b', '#e8912d', '#c678dd', '#e05661', '#56b6c2', '#98c379', '#d19a66']
|
|
30
|
+
|
|
31
|
+
/** 宿主服务共享状态容器(见模块注释;可变原始值一律经 core.* 访问)。 */
|
|
32
|
+
export interface HostState {
|
|
33
|
+
ctx: Context
|
|
34
|
+
llm: Context['llm']
|
|
35
|
+
fs: Context['fs']
|
|
36
|
+
groups: Map<string, GroupRecord>
|
|
37
|
+
sessions: Map<string, SessionRecord>
|
|
38
|
+
roles: Map<string, RoleRecord>
|
|
39
|
+
messages: Map<string, MessageRecord>
|
|
40
|
+
run: RunState
|
|
41
|
+
/** 持久化句柄;锁失败降级内存态为 null,dispose 后置 null。 */
|
|
42
|
+
store: Store | null
|
|
43
|
+
/** 快照版本号:touch() 递增。 */
|
|
44
|
+
revision: number
|
|
45
|
+
/** id 序号(hydrate 后推进到历史最大值 + 1)。 */
|
|
46
|
+
idSeq: number
|
|
47
|
+
nid: (p: string) => string
|
|
48
|
+
/** 最近一次创建的群组/会话,供客户端定位选中项。 */
|
|
49
|
+
lastCreated: LastCreated | null
|
|
50
|
+
newSession: (groupId: string, name?: string) => SessionRecord
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 创建共享状态容器(仅数据与稳定引用,不含行为)。 */
|
|
54
|
+
export function createHostState(ctx: Context): HostState {
|
|
55
|
+
const core: HostState = {
|
|
56
|
+
ctx,
|
|
57
|
+
llm: ctx.llm,
|
|
58
|
+
fs: ctx.fs,
|
|
59
|
+
groups: new Map(),
|
|
60
|
+
sessions: new Map(),
|
|
61
|
+
roles: new Map(),
|
|
62
|
+
messages: new Map(),
|
|
63
|
+
run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, childProc: null, finished: null },
|
|
64
|
+
store: null,
|
|
65
|
+
revision: 1,
|
|
66
|
+
idSeq: 1,
|
|
67
|
+
nid: (p: string): string => p + '-' + (core.idSeq++),
|
|
68
|
+
lastCreated: null,
|
|
69
|
+
newSession: (groupId: string, name?: string): SessionRecord => {
|
|
70
|
+
let n = 0
|
|
71
|
+
for (const s of core.sessions.values()) if (s.groupId === groupId) n++
|
|
72
|
+
const s: SessionRecord = {
|
|
73
|
+
id: randomUUID(),
|
|
74
|
+
groupId,
|
|
75
|
+
name: name || '会话 ' + (n + 1),
|
|
76
|
+
topic: '',
|
|
77
|
+
messageIds: [],
|
|
78
|
+
createdAt: Date.now(),
|
|
79
|
+
}
|
|
80
|
+
core.sessions.set(s.id, s)
|
|
81
|
+
return s
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
return core
|
|
85
|
+
}
|