@roaming-ai/dsh-group-chat 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/lib/client.js +176 -8
- package/lib/client.js.map +1 -1
- package/lib/index.js +110 -89
- package/lib/types/client/components/SpeakerOrb.d.ts +13 -0
- package/lib/types/core/types.d.ts +3 -2
- package/lib/types/host/persistence/persistence.d.ts +2 -4
- package/lib/types/host/persistence/store.d.ts +5 -1
- package/lib/types/host/service.d.ts +2 -2
- package/lib/types/host/state.d.ts +1 -0
- package/lib/types/host/tools/tools.d.ts +2 -1
- package/lib/types/index.d.ts +3 -0
- package/package.json +4 -1
- package/src/client/GroupChatPanel.tsx +2 -0
- package/src/client/components/ChatPanel.tsx +1 -1
- package/src/client/components/Composer.tsx +2 -2
- package/src/client/components/MessageFlow.tsx +6 -2
- package/src/client/components/SpeakerOrb.tsx +201 -0
- package/src/client/lib/styles.ts +9 -2
- package/src/core/types.ts +3 -2
- package/src/host/engine/conversation.ts +1 -1
- package/src/host/persistence/persistence.ts +57 -34
- package/src/host/persistence/store.ts +5 -1
- package/src/host/service.ts +4 -4
- package/src/host/state.ts +5 -2
- package/src/host/tools/tools.ts +40 -46
- package/src/index.ts +7 -5
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发言人执行态点阵光球(live 行头像内容):角色色单色点阵 Canvas 2D 动画。
|
|
3
|
+
* thinking(轨道热斑游走=等待首字节/推理中)与 listening(向外涟漪=正文
|
|
4
|
+
* 流出)两态间弹簧缩放 + 权重交叉淡入;裁掉参考实现 MatrixOrb 的 label、
|
|
5
|
+
* level 与 idle 态(本场景只有执行中一种挂载时机),尺寸钉死为头像内容盒。
|
|
6
|
+
* @module dsh-group-chat/client/SpeakerOrb
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react'
|
|
10
|
+
|
|
11
|
+
export type SpeakerOrbState = 'thinking' | 'listening'
|
|
12
|
+
|
|
13
|
+
const TAU = Math.PI * 2
|
|
14
|
+
const STATES: SpeakerOrbState[] = ['thinking', 'listening']
|
|
15
|
+
|
|
16
|
+
/** 头像内容盒:28px 外框减 2px 描边 ×2;圆形轮廓由 d 截断近似。 */
|
|
17
|
+
const SIZE = 24
|
|
18
|
+
const GRID = 7
|
|
19
|
+
/** 点阵铺开比例:外圈点贴近描边又不被 border-radius 裁切。 */
|
|
20
|
+
const SPREAD = 0.86
|
|
21
|
+
|
|
22
|
+
const SCALE: Record<SpeakerOrbState, number> = { thinking: 0.94, listening: 1 }
|
|
23
|
+
const STIFFNESS = 180
|
|
24
|
+
const DAMPING = 26
|
|
25
|
+
const ATTACK = 0.22
|
|
26
|
+
const RELEASE = 0.08
|
|
27
|
+
const BLEND = 0.16
|
|
28
|
+
|
|
29
|
+
/** thinking 态热斑轨道(点阵归一化坐标)。 */
|
|
30
|
+
const ORBITERS = [
|
|
31
|
+
{ radius: 0.62, speed: 2.2, phase: 0, spread: 0.42 },
|
|
32
|
+
{ radius: 0.4, speed: -1.7, phase: 2.1, spread: 0.36 },
|
|
33
|
+
{ radius: 0.8, speed: 1.15, phase: 4, spread: 0.34 },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
// no Math.abs here, its corners read as a snap at every trough
|
|
37
|
+
function envelope(t: number) {
|
|
38
|
+
const slow = 0.5 + 0.5 * Math.sin(t * 0.62 + 0.4)
|
|
39
|
+
const fast = 0.5 + 0.5 * Math.sin(t * 1.9 + 1.1)
|
|
40
|
+
return 0.22 + 0.78 * (0.45 + 0.55 * slow) * fast
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function intensityOf(
|
|
44
|
+
state: SpeakerOrbState,
|
|
45
|
+
d: number,
|
|
46
|
+
nx: number,
|
|
47
|
+
ny: number,
|
|
48
|
+
t: number,
|
|
49
|
+
amplitude: number,
|
|
50
|
+
) {
|
|
51
|
+
if (state === 'listening') {
|
|
52
|
+
const ripple = 0.5 + 0.5 * Math.sin(d * 4.2 - t * 3)
|
|
53
|
+
return 0.32 + amplitude * (0.34 + 0.38 * ripple)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let heat = 0
|
|
57
|
+
for (const o of ORBITERS) {
|
|
58
|
+
const a = t * o.speed + o.phase
|
|
59
|
+
const dx = nx - Math.cos(a) * o.radius
|
|
60
|
+
const dy = ny - Math.sin(a) * o.radius
|
|
61
|
+
heat += Math.exp(-(dx * dx + dy * dy) / (o.spread * o.spread))
|
|
62
|
+
}
|
|
63
|
+
return 0.26 + 0.8 * Math.min(1, heat)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// zoom changes devicePixelRatio, and a buffer built for the old one gets upscaled
|
|
67
|
+
function subscribeToZoom(onChange: () => void) {
|
|
68
|
+
window.addEventListener('resize', onChange)
|
|
69
|
+
return () => window.removeEventListener('resize', onChange)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function useDevicePixelRatio() {
|
|
73
|
+
return useSyncExternalStore(
|
|
74
|
+
subscribeToZoom,
|
|
75
|
+
() => Math.min(window.devicePixelRatio || 1, 4),
|
|
76
|
+
() => 1,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function SpeakerOrb(props: { state: SpeakerOrbState, color: string }): ReactNode {
|
|
81
|
+
const { state, color } = props
|
|
82
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
83
|
+
const stateRef = useRef(state)
|
|
84
|
+
const redrawRef = useRef<(() => void) | null>(null)
|
|
85
|
+
const dpr = useDevicePixelRatio()
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
stateRef.current = state
|
|
89
|
+
}, [state])
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const canvas = canvasRef.current
|
|
93
|
+
const ctx = canvas?.getContext('2d')
|
|
94
|
+
if (!canvas || !ctx) return
|
|
95
|
+
|
|
96
|
+
// scaling by buffer/size, not dpr, keeps the transform exact when it rounds
|
|
97
|
+
const buffer = Math.round(SIZE * dpr)
|
|
98
|
+
canvas.width = canvas.height = buffer
|
|
99
|
+
ctx.scale(buffer / SIZE, buffer / SIZE)
|
|
100
|
+
ctx.fillStyle = color
|
|
101
|
+
|
|
102
|
+
const half = (GRID - 1) / 2
|
|
103
|
+
const spacing = (SIZE * SPREAD) / (GRID - 1)
|
|
104
|
+
const maxRadius = spacing * 0.6
|
|
105
|
+
const center = SIZE / 2
|
|
106
|
+
|
|
107
|
+
const weights: Record<SpeakerOrbState, number> = { thinking: 0, listening: 0 }
|
|
108
|
+
weights[stateRef.current] = 1
|
|
109
|
+
|
|
110
|
+
const draw = (t: number, amplitude: number, scale: number) => {
|
|
111
|
+
ctx.clearRect(0, 0, SIZE, SIZE)
|
|
112
|
+
|
|
113
|
+
for (let iy = 0; iy < GRID; iy++) {
|
|
114
|
+
for (let ix = 0; ix < GRID; ix++) {
|
|
115
|
+
const nx = (ix - half) / half
|
|
116
|
+
const ny = (iy - half) / half
|
|
117
|
+
const d = Math.hypot(nx, ny)
|
|
118
|
+
// 1.12, not the square's 1.41 corner, is what makes the outline round
|
|
119
|
+
if (d > 1.12) continue
|
|
120
|
+
|
|
121
|
+
let blended = 0
|
|
122
|
+
for (const s of STATES) {
|
|
123
|
+
if (weights[s] < 0.001) continue
|
|
124
|
+
blended += weights[s] * intensityOf(s, d, nx, ny, t, amplitude)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const intensity = Math.min(1, Math.max(0, blended))
|
|
128
|
+
const radius = maxRadius * Math.exp(-d * d * 1.7) * intensity * scale
|
|
129
|
+
// anything under half a device pixel renders as haze, not a dot
|
|
130
|
+
if (radius * dpr < 0.5) continue
|
|
131
|
+
|
|
132
|
+
ctx.beginPath()
|
|
133
|
+
ctx.arc(
|
|
134
|
+
center + (ix - half) * spacing * scale,
|
|
135
|
+
center + (iy - half) * spacing * scale,
|
|
136
|
+
radius,
|
|
137
|
+
0,
|
|
138
|
+
TAU,
|
|
139
|
+
)
|
|
140
|
+
ctx.fill()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const reduce =
|
|
146
|
+
typeof window.matchMedia === 'function' &&
|
|
147
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
148
|
+
|
|
149
|
+
if (reduce) {
|
|
150
|
+
redrawRef.current = () => {
|
|
151
|
+
const current = stateRef.current
|
|
152
|
+
for (const s of STATES) weights[s] = s === current ? 1 : 0
|
|
153
|
+
draw(0, envelope(0), SCALE[current])
|
|
154
|
+
}
|
|
155
|
+
redrawRef.current()
|
|
156
|
+
return () => {
|
|
157
|
+
redrawRef.current = null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let t = 0
|
|
162
|
+
let amplitude = 0
|
|
163
|
+
let scale = SCALE[stateRef.current]
|
|
164
|
+
let velocity = 0
|
|
165
|
+
let last = performance.now()
|
|
166
|
+
let raf = 0
|
|
167
|
+
|
|
168
|
+
const frame = (now: number) => {
|
|
169
|
+
const dt = Math.min((now - last) / 1000, 0.05)
|
|
170
|
+
last = now
|
|
171
|
+
t += dt
|
|
172
|
+
|
|
173
|
+
const current = stateRef.current
|
|
174
|
+
const target = envelope(t)
|
|
175
|
+
const rate = target > amplitude ? ATTACK : RELEASE
|
|
176
|
+
amplitude += (target - amplitude) * (1 - Math.pow(1 - rate, dt * 60))
|
|
177
|
+
|
|
178
|
+
// per-state weights, so interrupting a change blends from what is on screen
|
|
179
|
+
const step = 1 - Math.pow(1 - BLEND, dt * 60)
|
|
180
|
+
for (const s of STATES) {
|
|
181
|
+
weights[s] += ((s === current ? 1 : 0) - weights[s]) * step
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
velocity += (-STIFFNESS * (scale - SCALE[current]) - DAMPING * velocity) * dt
|
|
185
|
+
scale += velocity * dt
|
|
186
|
+
|
|
187
|
+
draw(t, amplitude, scale)
|
|
188
|
+
raf = requestAnimationFrame(frame)
|
|
189
|
+
}
|
|
190
|
+
raf = requestAnimationFrame(frame)
|
|
191
|
+
|
|
192
|
+
return () => cancelAnimationFrame(raf)
|
|
193
|
+
// state stays out of the deps on purpose: the loop retargets, it never restarts
|
|
194
|
+
}, [color, dpr])
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
redrawRef.current?.()
|
|
198
|
+
}, [state])
|
|
199
|
+
|
|
200
|
+
return <canvas ref={canvasRef} aria-hidden className="dsgc-orb" style={{ width: SIZE, height: SIZE }} />
|
|
201
|
+
}
|
package/src/client/lib/styles.ts
CHANGED
|
@@ -104,6 +104,9 @@ export const CSS = [
|
|
|
104
104
|
'.dsgc-msg.mine{flex-direction:row-reverse}',
|
|
105
105
|
'.dsgc-avatar{width:28px;height:28px;box-sizing:border-box;border:2px solid var(--dsw-alias-border-l3,rgba(128,128,128,.4));border-radius:50%;background:var(--dsw-alias-bg-module-platform,rgba(128,128,128,.12));color:var(--dsw-alias-label-primary,inherit);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex:none;margin-top:1px}',
|
|
106
106
|
'.dsgc-avatar.mine{border-color:transparent;background:var(--dsw-alias-button-info-fill,#4f6ef7);color:var(--dsw-alias-label-primary-foreground,#fff)}',
|
|
107
|
+
/* 执行中头像点阵光球(SpeakerOrb):inline canvas 需 block 消除基线空隙,
|
|
108
|
+
尺寸由组件内联 style 钉死为头像内容盒 24px */
|
|
109
|
+
'.dsgc-avatar .dsgc-orb{display:block}',
|
|
107
110
|
'.dsgc-msgbody{max-width:76%;min-width:0;display:flex;flex-direction:column;gap:5px}',
|
|
108
111
|
'.dsgc-msg.mine .dsgc-msgbody{align-items:flex-end}',
|
|
109
112
|
'.dsgc-msghead{display:flex;gap:6px;align-items:center;min-width:0;font-size:12px;color:var(--dsw-alias-label-secondary,inherit)}',
|
|
@@ -201,8 +204,12 @@ export const CSS = [
|
|
|
201
204
|
/* @提及芯片(输入区内原子元素):色点 + 角色色淡底胶囊——弹层候选行同语言 */
|
|
202
205
|
'.dsgc-chipin{display:inline-flex;align-items:center;gap:4px;background:color-mix(in srgb,var(--role-color,#888) 15%,transparent);border-radius:999px;padding:1px 7px 1px 5px;margin:0 1px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,inherit);white-space:nowrap;user-select:all}',
|
|
203
206
|
'.dsgc-card .dsgc-sendrow{padding:0 12px 10px}',
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
/* 停止/清空按钮:实心红(Button primitive outline 变体的 hover 规则
|
|
208
|
+
background:interactive-bg-hover 特异度 (0,3,0) 会盖掉 (0,1,0) 的红底,
|
|
209
|
+
悬停时按钮变透明;加 button 类型选择器提为 (0,1,1)/(0,3,1),顺序无关
|
|
210
|
+
地压过变体,hover 补回红底并经 brightness 提亮) */
|
|
211
|
+
'button.dsgc-stopbtn{background:var(--dsw-alias-state-error-primary,#e5484d);border-color:transparent;color:#fff;font-weight:600}',
|
|
212
|
+
'button.dsgc-stopbtn:hover:not(:disabled){background:var(--dsw-alias-state-error-primary,#e5484d);filter:brightness(1.08);color:#fff}',
|
|
206
213
|
/* 标签固定在左;芯片在右侧独立换行,后续行与首行芯片左缘对齐(不折到「参与角色」下面)。
|
|
207
214
|
单行 24px;角色过多时随内容增高,避免固定高度把后续行溢出叠到下方输入卡上 */
|
|
208
215
|
'.dsgc-parts{display:flex;align-items:flex-start;gap:6px;min-height:24px}',
|
package/src/core/types.ts
CHANGED
|
@@ -149,7 +149,8 @@ export interface RunState {
|
|
|
149
149
|
queue: string[]
|
|
150
150
|
pendingConfirm: PendingConfirm | null
|
|
151
151
|
confirmSignal: { resolve: (allowed: boolean) => void } | null
|
|
152
|
-
|
|
152
|
+
/** 正在执行的 run_command 的中止句柄(stop/dispose 时 abort,执行器 kill 进程)。 */
|
|
153
|
+
commandAbort: AbortController | null
|
|
153
154
|
/** 最近一次 run 的结束标记:会话列表「已完成/已出错」状态的数据源。 */
|
|
154
155
|
finished: RunFinished | null
|
|
155
156
|
/** 原地重试时被覆盖的失败消息 id;普通 send 为 null。 */
|
|
@@ -210,7 +211,7 @@ export type SnapshotMessage = Omit<MessageRecord, 'reasoningFull' | 'thinkingSum
|
|
|
210
211
|
/** 发到客户端的全量快照(wire 形态;各表行由领域记录派生,字段增删由编译器同步)。 */
|
|
211
212
|
export interface Snapshot {
|
|
212
213
|
revision: number
|
|
213
|
-
run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | '
|
|
214
|
+
run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'commandAbort'>
|
|
214
215
|
lastCreated: LastCreated | null
|
|
215
216
|
groups: GroupRecord[]
|
|
216
217
|
sessions: SnapshotSession[]
|
|
@@ -379,7 +379,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
379
379
|
run.queue = []
|
|
380
380
|
run.pendingConfirm = null
|
|
381
381
|
run.confirmSignal = null
|
|
382
|
-
run.
|
|
382
|
+
run.commandAbort = null
|
|
383
383
|
run.stopping = false
|
|
384
384
|
run.replaceMessageId = null
|
|
385
385
|
touch()
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
8
9
|
import { repairFailedMessage } from '../../core/errors.ts'
|
|
9
10
|
import { messageJson, roleJson } from '../../core/json.ts'
|
|
10
11
|
import { sanitizeConstraints } from '../../core/constraints.ts'
|
|
@@ -25,6 +26,9 @@ interface SessionDocument {
|
|
|
25
26
|
messages?: Partial<MessageRecord>[]
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/** writeFileAtomic 权限位:私有数据 0o600、目录 0o700(对齐 Store.atomicWrite)。 */
|
|
30
|
+
const WRITE_FILE_OPTS = { mode: 0o600, dirMode: 0o700 } as const
|
|
31
|
+
|
|
28
32
|
/** 群组角色文件形态(hydrate 用)。 */
|
|
29
33
|
interface RolesDocument {
|
|
30
34
|
roles?: Partial<RoleRecord>[]
|
|
@@ -34,12 +38,10 @@ interface RolesDocument {
|
|
|
34
38
|
export interface Persistence {
|
|
35
39
|
/** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
|
|
36
40
|
schedulePersist: (targets?: { ledger?: boolean, session?: string | null, roles?: string | null, workspace?: string | null }) => void
|
|
37
|
-
/** 同步 flush:写全部脏文件(dispose 最终落盘用)。 */
|
|
38
|
-
flushNow: () => void
|
|
39
41
|
/** 删除群组/会话后摘除脏标记(对应文件已删/将删,flush 跳过)。 */
|
|
40
42
|
dropDirty: (targets: { session?: string | null, roles?: string | null, workspace?: string | null }) => void
|
|
41
|
-
/** dispose
|
|
42
|
-
release: () => void
|
|
43
|
+
/** dispose:停止新调度 → 等待挂起 flush → 最终 flush → 释放锁(异步)。 */
|
|
44
|
+
release: () => Promise<void>
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -56,12 +58,20 @@ export function createPersistence(core: HostState): Persistence {
|
|
|
56
58
|
}
|
|
57
59
|
const store = (): Store | null => core.store
|
|
58
60
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
61
|
+
// 脏标记按文件记(值 = 版本号,标记时递增);flush 期间被重新标脏的文件
|
|
62
|
+
// 保留标记待下一轮 flush(写成功后版本未变才清除),失败同理自动重试
|
|
63
|
+
const dirtySessions = new Map<string, number>()
|
|
64
|
+
const dirtyRoles = new Map<string, number>()
|
|
65
|
+
const dirtyWorkspace = new Map<string, number>()
|
|
66
|
+
let ledgerDirty = 0
|
|
64
67
|
let flushScheduled = false
|
|
68
|
+
// flush 串行链:writeFileAtomic 异步化后,防止并发 flush 对同一文件乱序
|
|
69
|
+
// rename(后写的旧内容盖住先写的新内容);链上任务逐个排队执行
|
|
70
|
+
let flushChain: Promise<void> = Promise.resolve()
|
|
71
|
+
|
|
72
|
+
const mark = (map: Map<string, number>, key: string): void => {
|
|
73
|
+
map.set(key, (map.get(key) ?? 0) + 1)
|
|
74
|
+
}
|
|
65
75
|
|
|
66
76
|
const ledgerDocument = (): LedgerDocument & { savedAt: number } => ({
|
|
67
77
|
schema: 3,
|
|
@@ -85,57 +95,61 @@ export function createPersistence(core: HostState): Persistence {
|
|
|
85
95
|
})
|
|
86
96
|
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)) })
|
|
87
97
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
/**
|
|
99
|
+
* 异步 flush(writeFileAtomic:wx 独占创建 + 随机后缀 tmp + rename,无
|
|
100
|
+
* per-file fsync——崩溃持久性对齐 DSH 基座标准):写全部脏文件;写成功且
|
|
101
|
+
* 写入期间未被重新标脏(版本未变)才清脏标记;每个实际发生 rename 的
|
|
102
|
+
* 目录一次 fsync(调用侧批量执行)。
|
|
103
|
+
*/
|
|
104
|
+
const flushAll = async (s: Store): Promise<void> => {
|
|
92
105
|
const fsyncDirs = new Set<string>()
|
|
93
|
-
for (const sid of [...dirtySessions]) {
|
|
106
|
+
for (const [sid, v] of [...dirtySessions]) {
|
|
94
107
|
const sess = core.sessions.get(sid)
|
|
95
108
|
if (!sess) {
|
|
96
109
|
dirtySessions.delete(sid)
|
|
97
110
|
continue
|
|
98
111
|
}
|
|
99
112
|
try {
|
|
100
|
-
|
|
101
|
-
dirtySessions.delete(sid)
|
|
113
|
+
await writeFileAtomic(s.sessionFile(sess.groupId, sess.id), JSON.stringify(sessionDocument(sess)), WRITE_FILE_OPTS)
|
|
114
|
+
if (dirtySessions.get(sid) === v) dirtySessions.delete(sid)
|
|
102
115
|
fsyncDirs.add(s.sessionsDir(sess.groupId))
|
|
103
116
|
} catch (e) {
|
|
104
117
|
console.error(`[dsh-group-chat] 会话 ${sid} 落盘失败(保留脏标记待重试):`, e)
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
|
-
for (const gid of [...dirtyRoles]) {
|
|
120
|
+
for (const [gid, v] of [...dirtyRoles]) {
|
|
108
121
|
const g = core.groups.get(gid)
|
|
109
122
|
if (!g) {
|
|
110
123
|
dirtyRoles.delete(gid)
|
|
111
124
|
continue
|
|
112
125
|
}
|
|
113
126
|
try {
|
|
114
|
-
|
|
115
|
-
dirtyRoles.delete(gid)
|
|
127
|
+
await writeFileAtomic(s.rolesFile(gid), JSON.stringify(rolesDocument(g)), WRITE_FILE_OPTS)
|
|
128
|
+
if (dirtyRoles.get(gid) === v) dirtyRoles.delete(gid)
|
|
116
129
|
fsyncDirs.add(s.groupDir(gid))
|
|
117
130
|
} catch (e) {
|
|
118
131
|
console.error(`[dsh-group-chat] 群组 ${gid} roles.json 落盘失败(保留脏标记待重试):`, e)
|
|
119
132
|
}
|
|
120
133
|
}
|
|
121
|
-
for (const gid of [...dirtyWorkspace]) {
|
|
134
|
+
for (const [gid, v] of [...dirtyWorkspace]) {
|
|
122
135
|
const g = core.groups.get(gid)
|
|
123
136
|
if (!g) {
|
|
124
137
|
dirtyWorkspace.delete(gid)
|
|
125
138
|
continue
|
|
126
139
|
}
|
|
127
140
|
try {
|
|
128
|
-
|
|
129
|
-
dirtyWorkspace.delete(gid)
|
|
141
|
+
await writeFileAtomic(s.workspaceFile(gid), (g.workspaceDir || '') + '\n', WRITE_FILE_OPTS)
|
|
142
|
+
if (dirtyWorkspace.get(gid) === v) dirtyWorkspace.delete(gid)
|
|
130
143
|
fsyncDirs.add(s.groupDir(gid))
|
|
131
144
|
} catch (e) {
|
|
132
145
|
console.error(`[dsh-group-chat] 群组 ${gid} workspaceDir 落盘失败(保留脏标记待重试):`, e)
|
|
133
146
|
}
|
|
134
147
|
}
|
|
135
|
-
if (ledgerDirty) {
|
|
148
|
+
if (ledgerDirty > 0) {
|
|
149
|
+
const v = ledgerDirty
|
|
136
150
|
try {
|
|
137
|
-
|
|
138
|
-
ledgerDirty =
|
|
151
|
+
await writeFileAtomic(s.ledgerFile, JSON.stringify(ledgerDocument(), null, 2), WRITE_FILE_OPTS)
|
|
152
|
+
if (ledgerDirty === v) ledgerDirty = 0
|
|
139
153
|
fsyncDirs.add(s.dir)
|
|
140
154
|
} catch (e) {
|
|
141
155
|
console.error('[dsh-group-chat] ledger.json 落盘失败(保留脏标记待重试):', e)
|
|
@@ -147,15 +161,22 @@ export function createPersistence(core: HostState): Persistence {
|
|
|
147
161
|
/** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
|
|
148
162
|
const schedulePersist = ({ ledger = false, session = null, roles: roleGroup = null, workspace = null }: { ledger?: boolean, session?: string | null, roles?: string | null, workspace?: string | null } = {}): void => {
|
|
149
163
|
if (store() === null) return
|
|
150
|
-
if (ledger) ledgerDirty
|
|
151
|
-
if (session) dirtySessions
|
|
152
|
-
if (roleGroup) dirtyRoles
|
|
153
|
-
if (workspace) dirtyWorkspace
|
|
164
|
+
if (ledger) ledgerDirty++
|
|
165
|
+
if (session) mark(dirtySessions, session)
|
|
166
|
+
if (roleGroup) mark(dirtyRoles, roleGroup)
|
|
167
|
+
if (workspace) mark(dirtyWorkspace, workspace)
|
|
154
168
|
if (flushScheduled) return
|
|
155
169
|
flushScheduled = true
|
|
156
170
|
void Promise.resolve().then(() => {
|
|
157
171
|
flushScheduled = false
|
|
158
|
-
|
|
172
|
+
// 排队进串行链;flushAll 按文件吞错(保留脏标记),此处兜底意外
|
|
173
|
+
// 逃逸的异常并把链复位为健康态,后续 flush 不被跳过
|
|
174
|
+
flushChain = flushChain.then(async () => {
|
|
175
|
+
const s = store()
|
|
176
|
+
if (s !== null) await flushAll(s)
|
|
177
|
+
}).catch((e) => {
|
|
178
|
+
console.error('[dsh-group-chat] 落盘 flush 异常(脏标记保留待重试):', e)
|
|
179
|
+
})
|
|
159
180
|
})
|
|
160
181
|
}
|
|
161
182
|
|
|
@@ -311,18 +332,20 @@ export function createPersistence(core: HostState): Persistence {
|
|
|
311
332
|
|
|
312
333
|
return {
|
|
313
334
|
schedulePersist,
|
|
314
|
-
flushNow,
|
|
315
335
|
dropDirty,
|
|
316
|
-
release(): void {
|
|
336
|
+
async release(): Promise<void> {
|
|
317
337
|
const s = store()
|
|
318
338
|
if (s === null) return
|
|
339
|
+
// 立即停止新调度并摘除句柄(后续 schedulePersist 直接 no-op);
|
|
340
|
+
// 持有的 store 引用继续完成最终落盘
|
|
341
|
+
core.store = null
|
|
319
342
|
try {
|
|
320
|
-
|
|
343
|
+
await flushChain
|
|
344
|
+
await flushAll(s)
|
|
321
345
|
} catch {}
|
|
322
346
|
try {
|
|
323
347
|
s.release()
|
|
324
348
|
} catch {}
|
|
325
|
-
core.store = null
|
|
326
349
|
},
|
|
327
350
|
}
|
|
328
351
|
}
|
|
@@ -126,7 +126,11 @@ export class Store {
|
|
|
126
126
|
} catch {}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* 同步原子写(tmp+fsync+rename):仅供构造期 v1 迁移(migrateV1)与测试
|
|
131
|
+
* 直用;运行时 flush 走 persistence.ts 的 writeFileAtomic(异步、wx 独占
|
|
132
|
+
* 创建 + 随机后缀 + 符号链接安全,无 per-file fsync,对齐 DSH 基座标准)。
|
|
133
|
+
*/
|
|
130
134
|
atomicWrite(file: string, text: string): void {
|
|
131
135
|
mkdirSync(dirname(file), { recursive: true })
|
|
132
136
|
const tmp = `${file}.tmp-${process.pid}`
|
package/src/host/service.ts
CHANGED
|
@@ -28,8 +28,8 @@ export interface GroupChatService {
|
|
|
28
28
|
subscribePush(push: () => void): () => void
|
|
29
29
|
/** 设置停用时中止正在进行的群聊。 */
|
|
30
30
|
stopAll(): void
|
|
31
|
-
/** 卸载/热重载:唤醒确认等待 + kill 子进程 →
|
|
32
|
-
dispose(): void
|
|
31
|
+
/** 卸载/热重载:唤醒确认等待 + kill 子进程 → 异步最终 flush → 释放锁(cordis 会 await)。 */
|
|
32
|
+
dispose(): Promise<void>
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -66,13 +66,13 @@ export function createGroupChatService(ctx: Context): GroupChatService {
|
|
|
66
66
|
bus.touch()
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
|
-
dispose(): void {
|
|
69
|
+
dispose(): Promise<void> {
|
|
70
70
|
core.run.stopping = true
|
|
71
71
|
if (core.run.pendingConfirm) tools.wakeConfirm()
|
|
72
72
|
tools.killChild()
|
|
73
73
|
materials.disposeFileSearch()
|
|
74
74
|
bus.dispose()
|
|
75
|
-
persist.release()
|
|
75
|
+
return persist.release()
|
|
76
76
|
},
|
|
77
77
|
}
|
|
78
78
|
}
|
package/src/host/state.ts
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from 'node:crypto'
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
-
// 类型合并:ctx.llm / ctx.fs / ctx.workspaceRegistry(宿主面)
|
|
11
|
+
// 类型合并:ctx.llm / ctx.fs / ctx.shell / ctx.workspaceRegistry(宿主面)
|
|
12
12
|
import type {} from '@deepseek-ai/dsh-llm'
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-fs'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-shell'
|
|
14
15
|
import type {} from '@deepseek-ai/dsh-workspace'
|
|
15
16
|
import type { Store } from './persistence/store.ts'
|
|
16
17
|
import type { GroupRecord, LastCreated, MessageRecord, RoleRecord, RunState, SessionRecord } from '../core/types.ts'
|
|
@@ -36,6 +37,7 @@ export interface HostState {
|
|
|
36
37
|
ctx: Context
|
|
37
38
|
llm: Context['llm']
|
|
38
39
|
fs: Context['fs']
|
|
40
|
+
shell: Context['shell']
|
|
39
41
|
groups: Map<string, GroupRecord>
|
|
40
42
|
sessions: Map<string, SessionRecord>
|
|
41
43
|
roles: Map<string, RoleRecord>
|
|
@@ -59,11 +61,12 @@ export function createHostState(ctx: Context): HostState {
|
|
|
59
61
|
ctx,
|
|
60
62
|
llm: ctx.llm,
|
|
61
63
|
fs: ctx.fs,
|
|
64
|
+
shell: ctx.shell,
|
|
62
65
|
groups: new Map(),
|
|
63
66
|
sessions: new Map(),
|
|
64
67
|
roles: new Map(),
|
|
65
68
|
messages: new Map(),
|
|
66
|
-
run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null,
|
|
69
|
+
run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, commandAbort: null, finished: null, replaceMessageId: null },
|
|
67
70
|
store: null,
|
|
68
71
|
revision: 1,
|
|
69
72
|
idSeq: 1,
|
package/src/host/tools/tools.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 工具执行(TOOLS.md §2:沙箱 / §3:确认闸门):read_file / list_dir /
|
|
3
3
|
* run_command 三件套;realpath 硬边界 + 分隔符比较;run_command 按群组
|
|
4
|
-
*
|
|
4
|
+
* 权限档位走逐条确认或直接执行,经 `shell` 服务(ctx.shell 沙箱执行器)
|
|
5
|
+
* 以 per-call sandboxPolicy 收紧到群工作区。
|
|
5
6
|
* @module dsh-group-chat/host/tools
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import { spawn, type ChildProcess } from 'node:child_process'
|
|
9
9
|
import { closeSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
10
10
|
import { isAbsolute, join, sep } from 'node:path'
|
|
11
11
|
import { CMD_CAPTURE_MAX_BYTES, CMD_OUTPUT_MAX_CHARS, READ_FILE_MAX_BYTES, RUN_CMD_TIMEOUT_MS, TOOL_SCHEMAS } from '../../core/tools.ts'
|
|
@@ -28,7 +28,7 @@ export interface Tools {
|
|
|
28
28
|
|
|
29
29
|
/** 创建工具面。 */
|
|
30
30
|
export function createTools(core: HostState, touch: () => void): Tools {
|
|
31
|
-
const { run } = core
|
|
31
|
+
const { run, shell } = core
|
|
32
32
|
|
|
33
33
|
/** realpath 硬边界:目标必须在群组工作区内(带分隔符比较,防 /ws/foo 放行 /ws/foobar)。 */
|
|
34
34
|
const resolveInWorkspace = (root: string, rawPath: unknown): { ok: true, target: string } | { ok: false, error: string } => {
|
|
@@ -92,45 +92,40 @@ export function createTools(core: HostState, touch: () => void): Tools {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* 经 `shell` 服务执行命令(TOOLS.md §2):cwd 与沙箱均收紧到群工作区根
|
|
97
|
+
* (workspace_write 档经 sandboxPolicy 强制;full_access 档对齐 DSH
|
|
98
|
+
* danger-full-access 语义免受限)。超时/中止由执行器 kill 进程并按首因
|
|
99
|
+
* 分类报告;runner 失效(如 SANDBOX_UNAVAILABLE)经 catch 报错不执行。
|
|
100
|
+
*/
|
|
101
|
+
const runCommandTool = async (g: GroupRecord, root: string, command: string): Promise<{ status: 'ok' | 'error', output: string }> => {
|
|
102
|
+
const abort = new AbortController()
|
|
103
|
+
run.commandAbort = abort
|
|
97
104
|
try {
|
|
98
|
-
|
|
105
|
+
const spec = shell.resolve({
|
|
106
|
+
command,
|
|
107
|
+
workdir: root,
|
|
108
|
+
timeoutMs: RUN_CMD_TIMEOUT_MS,
|
|
109
|
+
stdoutMaxBytes: CMD_CAPTURE_MAX_BYTES,
|
|
110
|
+
sandboxPolicy: g.permissionTier === 'full_access'
|
|
111
|
+
? { mode: 'danger-full-access' as const, workspaceRoot: root }
|
|
112
|
+
: { mode: 'workspace-write' as const, workspaceRoot: root },
|
|
113
|
+
signal: abort.signal,
|
|
114
|
+
})
|
|
115
|
+
const r = await shell.run(spec)
|
|
116
|
+
let text = r.stdout.text + r.stderr.text
|
|
117
|
+
if (r.stdout.truncated || r.stderr.truncated) text += '\n[输出采集超限,已截断(保留末尾)]'
|
|
118
|
+
if (r.sandbox?.denied) text += '\n[沙箱拦截了越界的文件操作]'
|
|
119
|
+
if (r.timedOut) text += '\n[执行超时(' + Math.round(RUN_CMD_TIMEOUT_MS / 1000) + 's),已强制终止]'
|
|
120
|
+
if (r.exitCode !== 0 && r.exitCode !== null && !r.timedOut) text += '\n[退出码 ' + r.exitCode + ']'
|
|
121
|
+
if (text.length > CMD_OUTPUT_MAX_CHARS) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + '\n…(输出超长,已截断)'
|
|
122
|
+
return { status: r.exitCode === 0 ? 'ok' : 'error', output: text || '(无输出)' }
|
|
99
123
|
} catch (e) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
run.childProc = child
|
|
104
|
-
let out = ''
|
|
105
|
-
let dropped = 0
|
|
106
|
-
let timedOut = false
|
|
107
|
-
const onChunk = (chunk: Buffer) => {
|
|
108
|
-
if (out.length < CMD_CAPTURE_MAX_BYTES) out += chunk.toString('utf8')
|
|
109
|
-
else dropped += chunk.length
|
|
110
|
-
}
|
|
111
|
-
child.stdout?.on('data', onChunk)
|
|
112
|
-
child.stderr?.on('data', onChunk)
|
|
113
|
-
const timer = setTimeout(() => {
|
|
114
|
-
timedOut = true
|
|
115
|
-
try {
|
|
116
|
-
child.kill('SIGKILL')
|
|
117
|
-
} catch {}
|
|
118
|
-
}, RUN_CMD_TIMEOUT_MS)
|
|
119
|
-
const finish = (result: { status: 'ok' | 'error', output: string }) => {
|
|
120
|
-
clearTimeout(timer)
|
|
121
|
-
if (run.childProc === child) run.childProc = null
|
|
122
|
-
resolve(result)
|
|
124
|
+
return { status: 'error', output: '无法启动命令:' + String((e && (e as Error).message) || e) }
|
|
125
|
+
} finally {
|
|
126
|
+
if (run.commandAbort === abort) run.commandAbort = null
|
|
123
127
|
}
|
|
124
|
-
|
|
125
|
-
child.on('close', (code) => {
|
|
126
|
-
let text = out.length > CMD_CAPTURE_MAX_BYTES ? out.slice(0, CMD_CAPTURE_MAX_BYTES) : out
|
|
127
|
-
if (dropped > 0) text += '\n[输出采集超限,已丢弃 ' + dropped + ' 字节]'
|
|
128
|
-
if (timedOut) text += '\n[执行超时(' + Math.round(RUN_CMD_TIMEOUT_MS / 1000) + 's),已强制终止]'
|
|
129
|
-
if (code !== 0 && code !== null && !timedOut) text += '\n[退出码 ' + code + ']'
|
|
130
|
-
if (text.length > CMD_OUTPUT_MAX_CHARS) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + '\n…(输出超长,已截断)'
|
|
131
|
-
finish({ status: code === 0 ? 'ok' : 'error', output: text || '(无输出)' })
|
|
132
|
-
})
|
|
133
|
-
})
|
|
128
|
+
}
|
|
134
129
|
|
|
135
130
|
/** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
|
|
136
131
|
const requestConfirmation = (toolCallId: string, args: Record<string, unknown>): Promise<boolean> => new Promise((resolve) => {
|
|
@@ -151,11 +146,10 @@ export function createTools(core: HostState, touch: () => void): Tools {
|
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
const killChild = (): void => {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
149
|
+
// 中止正在执行的命令:执行器收到 abort 信号后 kill 进程
|
|
150
|
+
try {
|
|
151
|
+
run.commandAbort?.abort()
|
|
152
|
+
} catch {}
|
|
159
153
|
}
|
|
160
154
|
|
|
161
155
|
const executeTool = async (g: GroupRecord, root: string, tc: { id: string, name: string, args: string }): Promise<ToolExecution> => {
|
|
@@ -175,9 +169,9 @@ export function createTools(core: HostState, touch: () => void): Tools {
|
|
|
175
169
|
const allowed = await requestConfirmation(tc.id, args)
|
|
176
170
|
if (run.stopping) res = { status: 'error', output: '对话已被用户停止,命令未执行' }
|
|
177
171
|
else if (!allowed) res = { status: 'denied', output: '用户拒绝了这次命令执行' }
|
|
178
|
-
else res = await runCommandTool(root, String(args.command || ''))
|
|
172
|
+
else res = await runCommandTool(g, root, String(args.command || ''))
|
|
179
173
|
} else {
|
|
180
|
-
res = await runCommandTool(root, String(args.command || ''))
|
|
174
|
+
res = await runCommandTool(g, root, String(args.command || ''))
|
|
181
175
|
}
|
|
182
176
|
} else {
|
|
183
177
|
res = { status: 'error', output: '未知工具:' + tc.name }
|