@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,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,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
|
+
}
|