@roaming-ai/dsh-group-chat 0.3.0 → 0.3.1
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 +89 -26
- package/lib/client.js +289 -321
- package/lib/client.js.map +1 -1
- package/lib/index.js +124 -132
- package/lib/types/client/components/AsidePanel.d.ts +3 -19
- package/lib/types/client/components/NavPanel.d.ts +9 -11
- package/lib/types/client/components/RoleDrawer.d.ts +3 -2
- package/lib/types/client/components/ToolRow.d.ts +2 -2
- package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
- package/lib/types/client/lib/model.d.ts +1 -7
- package/lib/types/client/utils/utils.d.ts +4 -4
- package/lib/types/core/json.d.ts +6 -0
- package/lib/types/core/types.d.ts +57 -63
- package/lib/types/host/engine/defaults.d.ts +13 -0
- package/lib/types/shared/file-mention-grammar.d.ts +3 -12
- package/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +7 -56
- package/src/client/components/AsidePanel.tsx +40 -15
- package/src/client/components/ConstraintList.tsx +1 -6
- package/src/client/components/MessageFlow.tsx +2 -1
- package/src/client/components/NavPanel.tsx +96 -78
- package/src/client/components/RoleDrawer.tsx +29 -7
- package/src/client/components/ToolRow.tsx +2 -2
- package/src/client/hooks/useComposer.ts +14 -53
- package/src/client/hooks/useGroupChatState.ts +2 -13
- package/src/client/lib/model.ts +1 -14
- package/src/client/utils/utils.ts +6 -6
- package/src/core/constraints.ts +6 -12
- package/src/core/errors.ts +2 -12
- package/src/core/json.ts +17 -0
- package/src/core/types.ts +27 -19
- package/src/host/api/actions.ts +14 -13
- package/src/host/engine/conversation.ts +49 -50
- package/src/host/engine/defaults.ts +28 -0
- package/src/host/engine/fold.ts +2 -17
- package/src/host/engine/retitle.ts +9 -31
- package/src/host/materials/materials.ts +2 -0
- package/src/host/tools/tools.ts +2 -2
- package/src/shared/file-mention-grammar.test.ts +1 -38
- package/src/shared/file-mention-grammar.ts +7 -33
- package/lib/types/client/Bubble.d.ts +0 -10
- package/lib/types/client/Glyph.d.ts +0 -12
- package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
- package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
- package/lib/types/client/PermissionSelect.d.ts +0 -13
- package/lib/types/client/RoleDrawer.d.ts +0 -17
- package/lib/types/client/ThinkRow.d.ts +0 -9
- package/lib/types/client/ToolRow.d.ts +0 -9
- package/lib/types/client/api.d.ts +0 -8
- package/lib/types/client/components.d.ts +0 -22
- package/lib/types/client/drawer.d.ts +0 -17
- package/lib/types/client/glyph.d.ts +0 -12
- package/lib/types/client/model.d.ts +0 -78
- package/lib/types/client/panel.d.ts +0 -10
- package/lib/types/client/settings.d.ts +0 -15
- package/lib/types/client/styles.d.ts +0 -10
- package/lib/types/client/ui.d.ts +0 -17
- package/lib/types/client/utils.d.ts +0 -22
- package/lib/types/host/actions.d.ts +0 -34
- package/lib/types/host/conversation.d.ts +0 -25
- package/lib/types/host/http.d.ts +0 -16
- package/lib/types/host/materials.d.ts +0 -32
- package/lib/types/host/persistence.d.ts +0 -31
- package/lib/types/host/routes.d.ts +0 -11
- package/lib/types/host/store.d.ts +0 -65
- package/lib/types/host/tools.d.ts +0 -28
package/src/core/types.ts
CHANGED
|
@@ -202,31 +202,39 @@ export interface FileSearchResult {
|
|
|
202
202
|
error?: string
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
/**
|
|
205
|
+
/** 快照内的会话行(wire 形态:不含 hydrate 用的固定标记与折入水位)。 */
|
|
206
|
+
export type SnapshotSession = Omit<SessionRecord, 'namePinned' | 'topicPinned' | 'constraintsUpToSeq'>
|
|
207
|
+
/** 快照内的消息行(wire 形态:思考全文与摘要不外发)。 */
|
|
208
|
+
export type SnapshotMessage = Omit<MessageRecord, 'reasoningFull' | 'thinkingSummary'>
|
|
209
|
+
|
|
210
|
+
/** 发到客户端的全量快照(wire 形态;各表行由领域记录派生,字段增删由编译器同步)。 */
|
|
206
211
|
export interface Snapshot {
|
|
207
212
|
revision: number
|
|
208
|
-
run:
|
|
213
|
+
run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'childProc'>
|
|
209
214
|
lastCreated: LastCreated | null
|
|
210
|
-
groups:
|
|
211
|
-
sessions:
|
|
212
|
-
roles:
|
|
213
|
-
messages:
|
|
215
|
+
groups: GroupRecord[]
|
|
216
|
+
sessions: SnapshotSession[]
|
|
217
|
+
roles: RoleRecord[]
|
|
218
|
+
messages: SnapshotMessage[]
|
|
214
219
|
error?: string
|
|
215
220
|
}
|
|
216
221
|
|
|
217
|
-
/** mutate
|
|
218
|
-
export
|
|
219
|
-
op: string
|
|
220
|
-
groupId?: string
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
topic?: string
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
enabled?: boolean
|
|
229
|
-
}
|
|
222
|
+
/** mutate 动作的参数形态(判别联合:按 op 收窄各分支字段;wire 上字段可缺,消费方各自守卫)。 */
|
|
223
|
+
export type MutateArgs =
|
|
224
|
+
| { op: 'createGroup', name?: string }
|
|
225
|
+
| { op: 'renameGroup', groupId: string, name?: string }
|
|
226
|
+
| { op: 'deleteGroup', groupId: string }
|
|
227
|
+
| { op: 'createSession', groupId: string, name?: string }
|
|
228
|
+
| { op: 'renameSession', sessionId: string, name?: string }
|
|
229
|
+
| { op: 'deleteSession', sessionId: string }
|
|
230
|
+
| { op: 'setTopic', sessionId: string, topic?: string }
|
|
231
|
+
| { op: 'upsertRole', groupId: string, role?: Partial<RoleRecord> }
|
|
232
|
+
| { op: 'deleteRole', roleId: string }
|
|
233
|
+
| { op: 'setRoleEnabled', roleId: string, enabled?: boolean }
|
|
234
|
+
| { op: 'setWorkspaceDir', groupId: string, path?: string }
|
|
235
|
+
| { op: 'setPermissionTier', groupId: string, tier?: string }
|
|
236
|
+
| { op: 'ackFinish' }
|
|
237
|
+
| { op: 'clearMessages', sessionId: string }
|
|
230
238
|
|
|
231
239
|
/** send 动作的参数形态。 */
|
|
232
240
|
export interface SendArgs {
|
package/src/host/api/actions.ts
CHANGED
|
@@ -36,7 +36,8 @@ export function createActions(core: HostState, deps: {
|
|
|
36
36
|
const { touch, snapshot, schedulePersist, dropDirty, appendMessage, runLoop, wakeConfirm, killChild, browse, fileSearch, disposeFileSearch } = deps
|
|
37
37
|
|
|
38
38
|
const mutate = (args: MutateArgs): Snapshot => {
|
|
39
|
-
|
|
39
|
+
// 直接属性别名保持判别联合收窄(调用方 handleAction 已保证 body 为对象)
|
|
40
|
+
const op = args.op
|
|
40
41
|
if (op === 'createGroup') {
|
|
41
42
|
const g: GroupRecord = { id: core.nid('grp'), name: String(args.name || '').trim() || '群组 ' + (groups.size + 1), workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
|
|
42
43
|
groups.set(g.id, g)
|
|
@@ -46,14 +47,14 @@ export function createActions(core: HostState, deps: {
|
|
|
46
47
|
schedulePersist({ ledger: true, session: sess.id })
|
|
47
48
|
touch()
|
|
48
49
|
} else if (op === 'renameGroup') {
|
|
49
|
-
const g = groups.get(args.groupId
|
|
50
|
+
const g = groups.get(args.groupId)
|
|
50
51
|
if (g && String(args.name || '').trim()) {
|
|
51
52
|
g.name = String(args.name).trim()
|
|
52
53
|
schedulePersist({ ledger: true })
|
|
53
54
|
touch()
|
|
54
55
|
}
|
|
55
56
|
} else if (op === 'deleteGroup') {
|
|
56
|
-
const g = groups.get(args.groupId
|
|
57
|
+
const g = groups.get(args.groupId)
|
|
57
58
|
if (!g) return { ...snapshot(), error: '群组不存在' }
|
|
58
59
|
if (groups.size <= 1) return { ...snapshot(), error: '至少保留一个群组' }
|
|
59
60
|
if (run.running) {
|
|
@@ -80,7 +81,7 @@ export function createActions(core: HostState, deps: {
|
|
|
80
81
|
schedulePersist({ ledger: true })
|
|
81
82
|
touch()
|
|
82
83
|
} else if (op === 'createSession') {
|
|
83
|
-
const g = groups.get(args.groupId
|
|
84
|
+
const g = groups.get(args.groupId)
|
|
84
85
|
if (!g) return { ...snapshot(), error: '群组不存在' }
|
|
85
86
|
const sess = core.newSession(g.id, String(args.name || '').trim() || undefined)
|
|
86
87
|
g.sessionIds.push(sess.id)
|
|
@@ -88,7 +89,7 @@ export function createActions(core: HostState, deps: {
|
|
|
88
89
|
schedulePersist({ ledger: true, session: sess.id })
|
|
89
90
|
touch()
|
|
90
91
|
} else if (op === 'renameSession') {
|
|
91
|
-
const sess = sessions.get(args.sessionId
|
|
92
|
+
const sess = sessions.get(args.sessionId)
|
|
92
93
|
if (sess && String(args.name || '').trim()) {
|
|
93
94
|
sess.name = String(args.name).trim()
|
|
94
95
|
sess.namePinned = true // 手动编辑 = 隐式固定:自动命名此后跳过名称
|
|
@@ -96,7 +97,7 @@ export function createActions(core: HostState, deps: {
|
|
|
96
97
|
touch()
|
|
97
98
|
}
|
|
98
99
|
} else if (op === 'deleteSession') {
|
|
99
|
-
const sess = sessions.get(args.sessionId
|
|
100
|
+
const sess = sessions.get(args.sessionId)
|
|
100
101
|
if (!sess) return { ...snapshot(), error: '会话不存在' }
|
|
101
102
|
const g = groups.get(sess.groupId)
|
|
102
103
|
if (g && g.sessionIds.length <= 1) return { ...snapshot(), error: '每个群组至少保留一个会话' }
|
|
@@ -115,7 +116,7 @@ export function createActions(core: HostState, deps: {
|
|
|
115
116
|
schedulePersist({ ledger: true })
|
|
116
117
|
touch()
|
|
117
118
|
} else if (op === 'setTopic') {
|
|
118
|
-
const sess = sessions.get(args.sessionId
|
|
119
|
+
const sess = sessions.get(args.sessionId)
|
|
119
120
|
if (sess) {
|
|
120
121
|
sess.topic = String(args.topic || '')
|
|
121
122
|
sess.topicPinned = true // 手动编辑 = 隐式固定:自动整理此后跳过主题
|
|
@@ -123,7 +124,7 @@ export function createActions(core: HostState, deps: {
|
|
|
123
124
|
touch()
|
|
124
125
|
}
|
|
125
126
|
} else if (op === 'upsertRole') {
|
|
126
|
-
const g = groups.get(args.groupId
|
|
127
|
+
const g = groups.get(args.groupId)
|
|
127
128
|
const r = args.role || {}
|
|
128
129
|
if (!g) return { ...snapshot(), error: '群组不存在' }
|
|
129
130
|
if (!r.name || !String(r.name).trim()) return { ...snapshot(), error: '角色名称不能为空' }
|
|
@@ -161,7 +162,7 @@ export function createActions(core: HostState, deps: {
|
|
|
161
162
|
touch()
|
|
162
163
|
}
|
|
163
164
|
} else if (op === 'deleteRole') {
|
|
164
|
-
const r = roles.get(args.roleId
|
|
165
|
+
const r = roles.get(args.roleId)
|
|
165
166
|
if (r) {
|
|
166
167
|
const gid = r.groupId
|
|
167
168
|
const g = groups.get(gid)
|
|
@@ -171,14 +172,14 @@ export function createActions(core: HostState, deps: {
|
|
|
171
172
|
touch()
|
|
172
173
|
}
|
|
173
174
|
} else if (op === 'setRoleEnabled') {
|
|
174
|
-
const r = roles.get(args.roleId
|
|
175
|
+
const r = roles.get(args.roleId)
|
|
175
176
|
if (r) {
|
|
176
177
|
r.enabled = !!args.enabled
|
|
177
178
|
schedulePersist({ roles: r.groupId })
|
|
178
179
|
touch()
|
|
179
180
|
}
|
|
180
181
|
} else if (op === 'setWorkspaceDir') {
|
|
181
|
-
const g = groups.get(args.groupId
|
|
182
|
+
const g = groups.get(args.groupId)
|
|
182
183
|
if (g) {
|
|
183
184
|
g.workspaceDir = String(args.path || '').trim()
|
|
184
185
|
schedulePersist({ workspace: g.id })
|
|
@@ -187,7 +188,7 @@ export function createActions(core: HostState, deps: {
|
|
|
187
188
|
} else if (op === 'setPermissionTier') {
|
|
188
189
|
const tier = asPermissionTier(args.tier)
|
|
189
190
|
if (!tier) return { ...snapshot(), error: '未知权限档位' }
|
|
190
|
-
const g = groups.get(args.groupId
|
|
191
|
+
const g = groups.get(args.groupId)
|
|
191
192
|
if (g) {
|
|
192
193
|
g.permissionTier = tier
|
|
193
194
|
// 降到仅可查看时若该群挂着待确认命令,自动拒绝(安全侧倾斜);
|
|
@@ -206,7 +207,7 @@ export function createActions(core: HostState, deps: {
|
|
|
206
207
|
touch()
|
|
207
208
|
}
|
|
208
209
|
} else if (op === 'clearMessages') {
|
|
209
|
-
const sess = sessions.get(args.sessionId
|
|
210
|
+
const sess = sessions.get(args.sessionId)
|
|
210
211
|
if (sess) {
|
|
211
212
|
if (run.running && run.sessionId === sess.id) return { ...snapshot(), error: '对话进行中,无法清空' }
|
|
212
213
|
for (const mid of sess.messageIds) messages.delete(mid)
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import { realpathSync } from 'node:fs'
|
|
8
8
|
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
9
|
-
import { constraintBlock, formatTranscriptLine, prefixIds,
|
|
9
|
+
import { constraintBlock, formatTranscriptLine, prefixIds, squeezedMessages, tempTranscript, WINDOW_SIZE } from '../../core/constraints.ts'
|
|
10
10
|
import { unwrapSpeakFailure } from '../../core/errors.ts'
|
|
11
11
|
import { TOOL_FLOOR_COST_CHARS, TOOL_REPEAT_LIMIT, TOOL_RESULTS_TOTAL_MAX, TRANSCRIPT_TOOL_SUMMARY } from '../../core/tools.ts'
|
|
12
12
|
import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord, SpeakResult, ToolCallRecord } from '../../core/types.ts'
|
|
13
13
|
import { asEffort } from '../../core/types.ts'
|
|
14
14
|
import type { HostState } from '../state.ts'
|
|
15
|
+
import { speakerNameOf } from './defaults.ts'
|
|
15
16
|
import { createFold } from './fold.ts'
|
|
16
17
|
import { createRetitle } from './retitle.ts'
|
|
17
18
|
import type { Materials } from '../materials/index.ts'
|
|
@@ -44,7 +45,6 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
44
45
|
// 标题/主题自动整理 + 窗口外约束折叠(finally 之后并列后台跑,不占用 run)
|
|
45
46
|
const retitle = createRetitle(core, { touch, schedulePersist })
|
|
46
47
|
const fold = createFold(core, { touch, schedulePersist })
|
|
47
|
-
const nameOf = (speaker: string): string => speakerLabel(speaker, (roles.get(speaker) || { name: undefined }).name)
|
|
48
48
|
|
|
49
49
|
const appendMessage = (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>): MessageRecord => {
|
|
50
50
|
const msg: MessageRecord = { id: core.nid('msg'), sessionId: sess.id, seq: sess.messageIds.length + 1, speaker, text, reasoning: undefined, model: undefined, error: undefined, ts: Date.now() }
|
|
@@ -64,57 +64,50 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
64
64
|
return msg
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** 原地覆盖既有消息(重试槽位);目标不存在或不属于本会话时返回 false,由调用方走追加。 */
|
|
68
|
+
const overwriteMessage = (sess: SessionRecord, replaceId: string | undefined, patch: Partial<MessageRecord>): boolean => {
|
|
69
|
+
if (!replaceId) return false
|
|
70
|
+
const existing = messages.get(replaceId)
|
|
71
|
+
if (!existing || existing.sessionId !== sess.id) return false
|
|
72
|
+
Object.assign(existing, patch, { ts: Date.now() })
|
|
73
|
+
touch()
|
|
74
|
+
schedulePersist({ session: sess.id })
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
/** 把失败回合写成该角色的消息(speaker = 角色 id),不再用系统胶囊顶替。 */
|
|
68
79
|
const writeFailure = (sess: SessionRecord, role: RoleRecord, err: unknown, replaceId?: string): void => {
|
|
69
80
|
const raw = unwrapSpeakFailure(String((err && (err as Error).message) || err))
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
existing.failedRoleId = role.id
|
|
82
|
-
existing.model = extra.model
|
|
83
|
-
existing.reasoning = undefined
|
|
84
|
-
existing.reasoningFull = undefined
|
|
85
|
-
existing.thinkingSummary = undefined
|
|
86
|
-
existing.toolCalls = undefined
|
|
87
|
-
existing.ts = Date.now()
|
|
88
|
-
touch()
|
|
89
|
-
schedulePersist({ session: sess.id })
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
}
|
|
81
|
+
const model = role.provider + ' / ' + role.model
|
|
82
|
+
const extra: Partial<MessageRecord> = { error: true, failedRoleId: role.id, model }
|
|
83
|
+
if (overwriteMessage(sess, replaceId, {
|
|
84
|
+
speaker: role.id,
|
|
85
|
+
text: raw,
|
|
86
|
+
...extra,
|
|
87
|
+
reasoning: undefined,
|
|
88
|
+
reasoningFull: undefined,
|
|
89
|
+
thinkingSummary: undefined,
|
|
90
|
+
toolCalls: undefined,
|
|
91
|
+
})) return
|
|
93
92
|
appendMessage(sess, role.id, raw, extra)
|
|
94
93
|
}
|
|
95
94
|
|
|
96
95
|
/** 成功发言写入:replaceId 存在则原地覆盖失败卡。 */
|
|
97
96
|
const writeSuccess = (sess: SessionRecord, role: RoleRecord, out: SpeakResult, replaceId?: string): void => {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
touch()
|
|
113
|
-
schedulePersist({ session: sess.id })
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
appendMessage(sess, role.id, out.text, extra)
|
|
97
|
+
const model = role.provider + ' / ' + role.model
|
|
98
|
+
const toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : undefined
|
|
99
|
+
if (overwriteMessage(sess, replaceId, {
|
|
100
|
+
speaker: role.id,
|
|
101
|
+
text: out.text,
|
|
102
|
+
error: undefined,
|
|
103
|
+
failedRoleId: undefined,
|
|
104
|
+
model,
|
|
105
|
+
reasoning: out.reasoning,
|
|
106
|
+
reasoningFull: undefined,
|
|
107
|
+
thinkingSummary: undefined,
|
|
108
|
+
toolCalls,
|
|
109
|
+
})) return
|
|
110
|
+
appendMessage(sess, role.id, out.text, { model, reasoning: out.reasoning, toolCalls: out.toolCalls })
|
|
118
111
|
}
|
|
119
112
|
|
|
120
113
|
/** 群聊记录 → 角色上下文块(最近 40 条 + 未折入临时原文)。失败卡不进上下文。重试截到该条之前。 */
|
|
@@ -123,10 +116,10 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
123
116
|
for (const mid of prefixIds(sess.messageIds, skipId).slice(-WINDOW_SIZE)) {
|
|
124
117
|
const m = messages.get(mid)
|
|
125
118
|
if (!m || m.id === skipId || m.error) continue
|
|
126
|
-
out.push(formatTranscriptLine(m,
|
|
119
|
+
out.push(formatTranscriptLine(m, speakerNameOf(roles, m.speaker)))
|
|
127
120
|
}
|
|
128
121
|
const live = out.join('\n\n')
|
|
129
|
-
const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) =>
|
|
122
|
+
const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => speakerNameOf(roles, m.speaker))
|
|
130
123
|
if (!temp) return live
|
|
131
124
|
if (!live) return temp
|
|
132
125
|
return temp + '\n\n' + live
|
|
@@ -297,7 +290,10 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
297
290
|
msgs.push({ id: ('g' + core.revision + '-a' + msgs.length) as Message['id'], role: 'assistant', content: assistantContent, source: { kind: 'model', provider: role.provider, model: role.model } })
|
|
298
291
|
for (const tc of round.tcs) {
|
|
299
292
|
if (run.stopping) break
|
|
300
|
-
|
|
293
|
+
// wsRoot 为 null 时 toolSchemas 必为空、不会产生 tool-call;防御性跳出,
|
|
294
|
+
// 不把空根传进沙箱
|
|
295
|
+
if (wsRoot === null) break
|
|
296
|
+
const res = await tools.executeTool(g, wsRoot, tc)
|
|
301
297
|
const output = String(res.output || '')
|
|
302
298
|
toolCalls.push({ tool: tc.name, args: res.args, status: res.status, output, durationMs: res.durationMs })
|
|
303
299
|
budgetUsed += Math.max(output.length, TOOL_FLOOR_COST_CHARS)
|
|
@@ -326,12 +322,15 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
326
322
|
}
|
|
327
323
|
|
|
328
324
|
const runLoop = async (sess: SessionRecord, opts?: { replaceMessageId?: string }): Promise<void> => {
|
|
329
|
-
const g = groups.get(sess.groupId)
|
|
325
|
+
const g = groups.get(sess.groupId) ?? null
|
|
330
326
|
const startCount = sess.messageIds.length
|
|
331
327
|
let failed = false // 发言失败 → 会话列表「已出错」标记
|
|
332
328
|
let replaceId = opts && opts.replaceMessageId
|
|
333
329
|
run.replaceMessageId = replaceId || null
|
|
334
330
|
try {
|
|
331
|
+
// 群组在 run 期间不可删(send 已校验 + deleteGroup 拦截运行中群组);
|
|
332
|
+
// 防御性早退——置空让 finally 走统一复位路径
|
|
333
|
+
if (!g) return
|
|
335
334
|
while (run.queue.length > 0 && !run.stopping) {
|
|
336
335
|
const roleId = run.queue.shift()!
|
|
337
336
|
const role = roles.get(roleId)
|
|
@@ -343,7 +342,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
343
342
|
const targetId = replaceId
|
|
344
343
|
replaceId = undefined
|
|
345
344
|
try {
|
|
346
|
-
const out = await speak(g
|
|
345
|
+
const out = await speak(g, sess, role, targetId)
|
|
347
346
|
// 停止后不落部分消息(已有「已停止本次对话」系统消息承接)
|
|
348
347
|
if (!run.stopping && (out.text || (Array.isArray(out.toolCalls) && out.toolCalls.length > 0))) {
|
|
349
348
|
writeSuccess(sess, role, out, targetId)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 引擎共享小件:DSH 默认模型读取 + 发言人展示名(retitle / fold / conversation 共用)。
|
|
3
|
+
* @module dsh-group-chat/host/engine/defaults
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { speakerLabel } from '../../core/constraints.ts'
|
|
7
|
+
import type { RoleRecord } from '../../core/types.ts'
|
|
8
|
+
import type { HostState } from '../state.ts'
|
|
9
|
+
|
|
10
|
+
/** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
|
|
11
|
+
export const defaultModel = (core: HostState): { provider: string, model: string } | null => {
|
|
12
|
+
try {
|
|
13
|
+
// cordis 语义:未 inject 的 ctx 属性访问会抛错("cannot get property without
|
|
14
|
+
// inject"),`?.` 接不住——可选消费必须走 reflect.get(读全局注册表,缺位返回
|
|
15
|
+
// undefined)。直接属性访问曾致 retitle 被静默跳过(标题/主题从不生成)。
|
|
16
|
+
const svc = core.ctx.reflect.get('agentDefaultModel')
|
|
17
|
+
const sel = svc ? svc.currentSelection() : null
|
|
18
|
+
return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
|
|
19
|
+
? { provider: sel.provider, model: sel.model }
|
|
20
|
+
: null
|
|
21
|
+
} catch {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 发言人展示名(roles 表内查角色名;user/system 固定文案,见 speakerLabel)。 */
|
|
27
|
+
export const speakerNameOf = (roles: Map<string, RoleRecord>, speaker: string): string =>
|
|
28
|
+
speakerLabel(speaker, roles.get(speaker)?.name)
|
package/src/host/engine/fold.ts
CHANGED
|
@@ -13,24 +13,11 @@ import {
|
|
|
13
13
|
parseConstraints,
|
|
14
14
|
squeezedMaxSeq,
|
|
15
15
|
squeezedMessages,
|
|
16
|
-
speakerLabel,
|
|
17
16
|
takeFoldBatch,
|
|
18
17
|
} from '../../core/constraints.ts'
|
|
19
18
|
import type { SessionRecord } from '../../core/types.ts'
|
|
20
19
|
import type { HostState } from '../state.ts'
|
|
21
|
-
|
|
22
|
-
/** DSH 默认模型(与 retitle 同一读取面;缺位返回 null)。 */
|
|
23
|
-
const defaultModel = (core: HostState): { provider: string, model: string } | null => {
|
|
24
|
-
try {
|
|
25
|
-
const svc = core.ctx.reflect.get('agentDefaultModel')
|
|
26
|
-
const sel = svc ? svc.currentSelection() : null
|
|
27
|
-
return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
|
|
28
|
-
? { provider: sel.provider, model: sel.model }
|
|
29
|
-
: null
|
|
30
|
-
} catch {
|
|
31
|
-
return null
|
|
32
|
-
}
|
|
33
|
-
}
|
|
20
|
+
import { defaultModel, speakerNameOf } from './defaults.ts'
|
|
34
21
|
|
|
35
22
|
/**
|
|
36
23
|
* 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
|
|
@@ -41,13 +28,11 @@ export function createFold(core: HostState, deps: { touch: () => void, scheduleP
|
|
|
41
28
|
const { touch, schedulePersist } = deps
|
|
42
29
|
const folding = new Set<string>()
|
|
43
30
|
|
|
44
|
-
const nameOf = (speaker: string): string => speakerLabel(speaker, (roles.get(speaker) || { name: undefined }).name)
|
|
45
|
-
|
|
46
31
|
return async (sess: SessionRecord): Promise<void> => {
|
|
47
32
|
if (folding.has(sess.id)) return
|
|
48
33
|
const squeezed = squeezedMessages(messages, sess)
|
|
49
34
|
if (!squeezed.length) return
|
|
50
|
-
const input = takeFoldBatch(squeezed, (m) =>
|
|
35
|
+
const input = takeFoldBatch(squeezed, (m) => speakerNameOf(roles, m.speaker))
|
|
51
36
|
const watermark = squeezedMaxSeq(input.consumed)
|
|
52
37
|
if (watermark <= 0) return
|
|
53
38
|
|
|
@@ -6,55 +6,33 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
9
|
+
import { looseJson } from '../../core/json.ts'
|
|
9
10
|
import type { SessionRecord } from '../../core/types.ts'
|
|
10
11
|
import { DEFAULT_SESSION_NAME, type HostState } from '../state.ts'
|
|
12
|
+
import { defaultModel, speakerNameOf } from './defaults.ts'
|
|
11
13
|
|
|
12
14
|
/** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
|
|
13
15
|
const isPlaceholderName = (name: string): boolean =>
|
|
14
16
|
name === DEFAULT_SESSION_NAME || /^会话 \d+$/.test(name)
|
|
15
17
|
|
|
16
|
-
/** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
|
|
17
|
-
const defaultModel = (core: HostState): { provider: string, model: string } | null => {
|
|
18
|
-
try {
|
|
19
|
-
// cordis 语义:未 inject 的 ctx 属性访问会抛错("cannot get property without
|
|
20
|
-
// inject"),`?.` 接不住——可选消费必须走 reflect.get(读全局注册表,缺位返回
|
|
21
|
-
// undefined)。直接属性访问曾致 retitle 被静默跳过(标题/主题从不生成)。
|
|
22
|
-
const svc = core.ctx.reflect.get('agentDefaultModel')
|
|
23
|
-
const sel = svc ? svc.currentSelection() : null
|
|
24
|
-
return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
|
|
25
|
-
? { provider: sel.provider, model: sel.model }
|
|
26
|
-
: null
|
|
27
|
-
} catch {
|
|
28
|
-
return null
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
18
|
/** 命名输入:最近 40 条非系统消息的紧凑转写(每条 500 字符封顶,命名不需要全文)。 */
|
|
33
19
|
const titleTranscript = (core: HostState, sess: SessionRecord): string => {
|
|
34
20
|
const out: string[] = []
|
|
35
21
|
for (const mid of sess.messageIds.slice(-40)) {
|
|
36
22
|
const m = core.messages.get(mid)
|
|
37
23
|
if (!m || m.speaker === 'system' || m.error || !m.text) continue
|
|
38
|
-
|
|
39
|
-
out.push('【' + name + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
|
|
24
|
+
out.push('【' + speakerNameOf(core.roles, m.speaker) + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
|
|
40
25
|
}
|
|
41
26
|
return out.join('\n')
|
|
42
27
|
}
|
|
43
28
|
|
|
44
|
-
/** 宽容解析模型输出:剥代码围栏 →
|
|
29
|
+
/** 宽容解析模型输出:剥代码围栏 → looseJson → 校验并封顶字段。 */
|
|
45
30
|
const parseRetitle = (raw: string): { name?: string, topic?: string } => {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const o = JSON.parse(body.slice(l, r + 1)) as { name?: unknown, topic?: unknown }
|
|
52
|
-
const name = typeof o.name === 'string' ? o.name.trim() : ''
|
|
53
|
-
const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
|
|
54
|
-
return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
|
|
55
|
-
} catch {
|
|
56
|
-
return {}
|
|
57
|
-
}
|
|
31
|
+
const o = looseJson(raw.replace(/```(?:json)?/g, ''))
|
|
32
|
+
if (!o) return {}
|
|
33
|
+
const name = typeof o.name === 'string' ? o.name.trim() : ''
|
|
34
|
+
const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
|
|
35
|
+
return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
|
|
58
36
|
}
|
|
59
37
|
|
|
60
38
|
/**
|
|
@@ -64,6 +64,8 @@ export function createMaterials(core: HostState): Materials {
|
|
|
64
64
|
|
|
65
65
|
/** 逐候选 stat,返回第一个存在的目标;都不存在时返回首候选与全部尝试。 */
|
|
66
66
|
const resolveMaterialTarget = async (raw: unknown) => {
|
|
67
|
+
// candidatePaths 构造上恒返回非空候选数组('' → [HOME],其余分支均单值或
|
|
68
|
+
// 非空 map),first 必在;! 仅为取首候选,无空指针风险
|
|
67
69
|
const candidates = await candidatePaths(raw)
|
|
68
70
|
let first: { target: import('@deepseek-ai/dsh-fs').FsTarget, path: string } | undefined
|
|
69
71
|
for (const c of candidates) {
|
package/src/host/tools/tools.ts
CHANGED
|
@@ -9,7 +9,7 @@ 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'
|
|
12
|
-
import type { GroupRecord,
|
|
12
|
+
import type { GroupRecord, PendingConfirm, ToolExecution } from '../../core/types.ts'
|
|
13
13
|
import type { HostState } from '../state.ts'
|
|
14
14
|
|
|
15
15
|
/** 工具面。 */
|
|
@@ -134,7 +134,7 @@ export function createTools(core: HostState, touch: () => void): Tools {
|
|
|
134
134
|
|
|
135
135
|
/** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
|
|
136
136
|
const requestConfirmation = (toolCallId: string, args: Record<string, unknown>): Promise<boolean> => new Promise((resolve) => {
|
|
137
|
-
run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as
|
|
137
|
+
run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as PendingConfirm['args'] }
|
|
138
138
|
run.confirmSignal = { resolve }
|
|
139
139
|
touch()
|
|
140
140
|
})
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { describe, expect, it } from 'vitest'
|
|
7
|
-
import { activeAtToken
|
|
7
|
+
import { activeAtToken } from './file-mention-grammar.ts'
|
|
8
8
|
|
|
9
9
|
describe('activeAtToken', () => {
|
|
10
10
|
it('extracts plain @path at cursor', () => {
|
|
@@ -38,40 +38,3 @@ describe('activeAtToken', () => {
|
|
|
38
38
|
expect(activeAtToken('\t@"test', 7)).toEqual({ prefix: '@"test', query: 'test', quoted: true })
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
|
-
|
|
42
|
-
describe('formatFileMention', () => {
|
|
43
|
-
it('formats plain file without whitespace', () => {
|
|
44
|
-
expect(formatFileMention({ kind: 'file', path: 'README.md' }, false)).toBe('@README.md')
|
|
45
|
-
expect(formatFileMention({ kind: 'file', path: 'src/index.ts' }, false)).toBe('@src/index.ts')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('formats plain directory with trailing slash', () => {
|
|
49
|
-
expect(formatFileMention({ kind: 'directory', path: 'src' }, false)).toBe('@src/')
|
|
50
|
-
expect(formatFileMention({ kind: 'directory', path: 'lib/utils' }, false)).toBe('@lib/utils/')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('quotes file with whitespace and closes quote', () => {
|
|
54
|
-
expect(formatFileMention({ kind: 'file', path: 'My Document.txt' }, false)).toBe('@"My Document.txt"')
|
|
55
|
-
expect(formatFileMention({ kind: 'file', path: 'src/my file.ts' }, false)).toBe('@"src/my file.ts"')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('quotes directory with whitespace but keeps quote open', () => {
|
|
59
|
-
expect(formatFileMention({ kind: 'directory', path: 'My Folder' }, false)).toBe('@"My Folder/')
|
|
60
|
-
expect(formatFileMention({ kind: 'directory', path: 'src/my lib' }, false)).toBe('@"src/my lib/')
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('preserves quote even when unnecessary', () => {
|
|
64
|
-
expect(formatFileMention({ kind: 'file', path: 'plain.txt' }, true)).toBe('@"plain.txt"')
|
|
65
|
-
expect(formatFileMention({ kind: 'directory', path: 'lib' }, true)).toBe('@"lib/')
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('rejects paths with control characters', () => {
|
|
69
|
-
expect(formatFileMention({ kind: 'file', path: 'file\x00.txt' }, false)).toBeUndefined()
|
|
70
|
-
expect(formatFileMention({ kind: 'file', path: 'file\n.txt' }, false)).toBeUndefined()
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('rejects paths with embedded quotes', () => {
|
|
74
|
-
expect(formatFileMention({ kind: 'file', path: 'file"name.txt' }, false)).toBeUndefined()
|
|
75
|
-
expect(formatFileMention({ kind: 'directory', path: 'dir"name' }, false)).toBeUndefined()
|
|
76
|
-
})
|
|
77
|
-
})
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vendored @file token grammar from dsh-file-reference (browser-safe, zero Node API).
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Sourced from @deepseek-ai/dsh-file-reference/lib/types/grammar.js
|
|
5
5
|
* Cannot import directly due to client bundle purity gate (dsh-client-bundle-purity).
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* 只保留输入侧的 @token 识别(activeAtToken);候选的文本化(formatFileMention)
|
|
8
|
+
* 未被本插件采用——目录钻入走「插入整颗芯片」语义,见 docs/DESIGN.md Composer 契约。
|
|
9
|
+
*
|
|
7
10
|
* @module dsh-group-chat/shared
|
|
8
11
|
*/
|
|
9
12
|
|
|
@@ -16,22 +19,17 @@ export interface AtToken {
|
|
|
16
19
|
quoted: boolean
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
export interface FileCandidate {
|
|
20
|
-
kind: 'file' | 'directory'
|
|
21
|
-
path: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
22
|
/**
|
|
25
23
|
* Extract active @token at cursor position.
|
|
26
24
|
* Matches @ at word boundary (line start or after whitespace).
|
|
27
|
-
*
|
|
25
|
+
*
|
|
28
26
|
* @param line - Current line text
|
|
29
27
|
* @param cursor - Cursor offset in line
|
|
30
28
|
* @returns Token or undefined if no active @token at cursor
|
|
31
29
|
*/
|
|
32
30
|
export function activeAtToken(line: string, cursor: number): AtToken | undefined {
|
|
33
31
|
const before = line.slice(0, cursor)
|
|
34
|
-
|
|
32
|
+
|
|
35
33
|
// Try quoted @"... first (more specific)
|
|
36
34
|
const quotedMatch = /(?:^|\s)@"([^"]*)$/.exec(before)
|
|
37
35
|
if (quotedMatch) {
|
|
@@ -56,27 +54,3 @@ export function activeAtToken(line: string, cursor: number): AtToken | undefined
|
|
|
56
54
|
|
|
57
55
|
return undefined
|
|
58
56
|
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Format file/directory candidate as @mention text.
|
|
62
|
-
*
|
|
63
|
-
* @param candidate - File or directory to format
|
|
64
|
-
* @param preserveQuote - Keep quotes even when unnecessary (for drill-down continuity)
|
|
65
|
-
* @returns Formatted @path or undefined for invalid paths
|
|
66
|
-
*/
|
|
67
|
-
export function formatFileMention(candidate: FileCandidate, preserveQuote: boolean): string | undefined {
|
|
68
|
-
const { path } = candidate
|
|
69
|
-
|
|
70
|
-
// Reject control characters and embedded quotes
|
|
71
|
-
if (/[\x00-\x1F"]/.test(path)) return undefined
|
|
72
|
-
|
|
73
|
-
const needsQuote = /\s/.test(path)
|
|
74
|
-
const useQuote = needsQuote || preserveQuote
|
|
75
|
-
|
|
76
|
-
if (candidate.kind === 'file') {
|
|
77
|
-
return useQuote ? `@"${path}"` : `@${path}`
|
|
78
|
-
} else {
|
|
79
|
-
// Directory: trailing slash, quote stays open for drill-down
|
|
80
|
-
return useQuote ? `@"${path}/` : `@${path}/`
|
|
81
|
-
}
|
|
82
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 聊天消息气泡(user / system / 角色发言)。
|
|
3
|
-
* @module dsh-group-chat/client/Bubble
|
|
4
|
-
*/
|
|
5
|
-
import type { ReactNode } from 'react';
|
|
6
|
-
import { type ClientSnapshot } from './model.ts';
|
|
7
|
-
export declare function Bubble(props: {
|
|
8
|
-
snap: ClientSnapshot;
|
|
9
|
-
m: ClientSnapshot['messages'][number];
|
|
10
|
-
}): ReactNode;
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 侧边栏入口图标。
|
|
3
|
-
*
|
|
4
|
-
* 整行命中层携带 “newSession” 类名:任务看板等 DOM 注入式面板用
|
|
5
|
-
* [class*="newSession"] 识别“侧边栏导航点击”并自动收起自己,与点击
|
|
6
|
-
* “新建会话”行为一致——类名不可哈希化。
|
|
7
|
-
* @module dsh-group-chat/client/glyph
|
|
8
|
-
*/
|
|
9
|
-
import type { ReactNode } from 'react';
|
|
10
|
-
export declare function Glyph(props: {
|
|
11
|
-
size?: number;
|
|
12
|
-
}): ReactNode;
|